diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b3ed9d..dab8e62b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.150] — 2026-05-28 — Release DV (stage-batch32 — single-PR reasoning-effort agent metadata) + +### Fixed + +- Reasoning-effort capability detection now consults Hermes Agent's `models.dev` metadata before falling back to WebUI-local provider/model prefix heuristics. xAI OAuth Grok models (e.g. `grok-4.3`) and other native/provider-specific catalogs that Hermes Agent already knows about now show the reasoning-effort chip without requiring a per-provider WebUI allowlist update. Existing exact-resolver paths (ACP unsupported, Copilot/GitHub effort subsets, OpenAI Codex, LM Studio live probing) keep their authoritative behavior — the new metadata lookup sits between those resolvers and the broad heuristic fallback. Metadata `supports_reasoning=False` is treated as authoritative so known non-reasoning variants stay hidden. Also: the no-query boot path of `/api/reasoning` now hydrates against the configured default model so the chip can populate before the front-end has session model context. (#3017) + ## [v0.51.149] — 2026-05-28 — Release DU (stage-batch31 — hyphenated session ids + prefill role consistency) ### Fixed diff --git a/api/config.py b/api/config.py index 34dd5e90..cfe1cec8 100644 --- a/api/config.py +++ b/api/config.py @@ -2134,6 +2134,38 @@ def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]: return [] +def _models_dev_reasoning_efforts(model_id: str, provider_id: str) -> list[str] | None: + """Return reasoning efforts from Hermes Agent model metadata when known. + + ``None`` means the metadata source is unavailable or has no answer, so the + caller should continue to compatibility fallbacks. A concrete list (including + ``[]``) is authoritative. + """ + model = _strip_provider_hint_for_reasoning(model_id) + provider = str(provider_id or "").strip().lower() + if not model or not provider: + return None + + try: + from agent.models_dev import get_model_capabilities + except Exception: + return None + + try: + capabilities = get_model_capabilities(provider=provider, model=model) + except Exception: + return None + if capabilities is None: + return None + + supports_reasoning = getattr(capabilities, "supports_reasoning", None) + if supports_reasoning is True: + return list(VALID_REASONING_EFFORTS) + if supports_reasoning is False: + return [] + return None + + def resolve_model_reasoning_efforts( model_id: str | None = None, provider_id: str | None = None, @@ -2156,51 +2188,42 @@ def resolve_model_reasoning_efforts( if provider in {"cursor-acp", "copilot-acp"}: return [] + hinted_model = _strip_provider_hint_for_reasoning(model) + try: from hermes_cli.models import ( github_model_reasoning_efforts, lmstudio_model_reasoning_options, ) except Exception: - return _heuristic_reasoning_efforts(model, provider) + if provider in {"copilot", "github-copilot"}: + return _heuristic_reasoning_efforts(hinted_model, provider) + else: + if provider in {"copilot", "github-copilot"}: + return github_model_reasoning_efforts(hinted_model) - hinted_model = _strip_provider_hint_for_reasoning(model) - if provider in {"copilot", "github-copilot"}: - return github_model_reasoning_efforts(hinted_model) + if provider == "openai-codex": + bare = hinted_model.rsplit("/", 1)[-1] + return github_model_reasoning_efforts(bare) - if provider == "openai-codex": - bare = hinted_model.rsplit("/", 1)[-1] - return github_model_reasoning_efforts(bare) - - if provider == "lmstudio": - probe_base = resolved_base_url or _get_provider_base_url(provider) - opts = lmstudio_model_reasoning_options(model, probe_base) - normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()] - if not normalized or set(normalized).issubset({"off"}): + if provider == "lmstudio": + probe_base = resolved_base_url or _get_provider_base_url(provider) + opts = lmstudio_model_reasoning_options(hinted_model, probe_base) + normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()] + if not normalized or set(normalized).issubset({"off"}): + return [] + level_opts = [opt for opt in normalized if opt in VALID_REASONING_EFFORTS] + if level_opts: + return list(dict.fromkeys(level_opts)) + if set(normalized).issubset({"off", "on"}): + return [] return [] - level_opts = [opt for opt in normalized if opt in VALID_REASONING_EFFORTS] - if level_opts: - return list(dict.fromkeys(level_opts)) - if set(normalized).issubset({"off", "on"}): - return [] - return [] - model_lower = model.lower() - prefixes = ( - "deepseek/", - "anthropic/", - "openai/", - "x-ai/", - "google/gemini-2", - "google/gemma-4", - "qwen/qwen3", - "tencent/hy3-preview", - "xiaomi/", - ) - if any(model_lower.startswith(prefix) for prefix in prefixes): - return list(VALID_REASONING_EFFORTS) + metadata_efforts = _models_dev_reasoning_efforts(hinted_model, provider) + if metadata_efforts is not None: + return metadata_efforts - return [] + return _heuristic_reasoning_efforts(hinted_model, provider) def get_reasoning_status( @@ -2221,10 +2244,23 @@ def get_reasoning_status( agent_cfg = config_data.get("agent") or {} show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None + + resolve_model = model_id + resolve_provider = provider_id + resolve_base_url = base_url + if not resolve_model: + model_cfg = config_data.get("model") or {} + if isinstance(model_cfg, dict): + resolve_model = str(model_cfg.get("default") or "").strip() or None + if not resolve_provider and model_cfg.get("provider"): + resolve_provider = str(model_cfg["provider"]).strip() + if not resolve_base_url and model_cfg.get("base_url"): + resolve_base_url = str(model_cfg["base_url"]).strip() + supported_efforts = resolve_model_reasoning_efforts( - model_id, - provider_id=provider_id, - base_url=base_url, + resolve_model, + provider_id=resolve_provider, + base_url=resolve_base_url, ) return { # Match CLI default (True if unset in config.yaml) diff --git a/tests/test_models_dev_reasoning.py b/tests/test_models_dev_reasoning.py new file mode 100644 index 00000000..f474e3fe --- /dev/null +++ b/tests/test_models_dev_reasoning.py @@ -0,0 +1,110 @@ +"""Regression coverage for Agent model-metadata reasoning capability lookup.""" + +import sys +import types +from types import SimpleNamespace + + +def _install_fake_models_dev(monkeypatch, fake_fn): + fake_agent = types.ModuleType("agent") + fake_models_dev = types.ModuleType("agent.models_dev") + setattr(fake_models_dev, "get_model_capabilities", fake_fn) + setattr(fake_agent, "models_dev", fake_models_dev) + monkeypatch.setitem(sys.modules, "agent", fake_agent) + monkeypatch.setitem(sys.modules, "agent.models_dev", fake_models_dev) + + +def test_models_dev_true_returns_full_efforts(monkeypatch): + _install_fake_models_dev( + monkeypatch, + lambda provider, model: SimpleNamespace(supports_reasoning=True), + ) + + import api.config as cfg + + assert cfg._models_dev_reasoning_efforts("grok-4.3", "xai-oauth") == list( + cfg.VALID_REASONING_EFFORTS + ) + + +def test_models_dev_false_returns_authoritative_empty(monkeypatch): + _install_fake_models_dev( + monkeypatch, + lambda provider, model: SimpleNamespace(supports_reasoning=False), + ) + + import api.config as cfg + + assert cfg._models_dev_reasoning_efforts("grok-4.20-non-reasoning", "xai-oauth") == [] + + +def test_models_dev_unknown_allows_compatibility_fallback(monkeypatch): + _install_fake_models_dev(monkeypatch, lambda provider, model: None) + + import api.config as cfg + + assert cfg.resolve_model_reasoning_efforts( + "x-ai/grok-4", provider_id="openrouter" + ) == list(cfg.VALID_REASONING_EFFORTS) + + +def test_xai_oauth_grok_uses_agent_metadata(monkeypatch): + seen = [] + + def fake_capabilities(provider, model): + seen.append((provider, model)) + return SimpleNamespace(supports_reasoning=True) + + _install_fake_models_dev(monkeypatch, fake_capabilities) + + import api.config as cfg + + assert cfg.resolve_model_reasoning_efforts( + "@xai-oauth:grok-4.3", provider_id="xai-oauth" + ) == list(cfg.VALID_REASONING_EFFORTS) + assert seen == [("xai-oauth", "grok-4.3")] + + +def test_models_dev_false_suppresses_prefix_heuristic(monkeypatch): + _install_fake_models_dev( + monkeypatch, + lambda provider, model: SimpleNamespace(supports_reasoning=False), + ) + + import api.config as cfg + + assert cfg.resolve_model_reasoning_efforts( + "x-ai/grok-4-non-reasoning", provider_id="openrouter" + ) == [] + + +def test_get_reasoning_status_uses_config_default_model(monkeypatch, tmp_path): + _install_fake_models_dev( + monkeypatch, + lambda provider, model: SimpleNamespace(supports_reasoning=True) + if (provider, model) == ("xai-oauth", "grok-4.3") + else None, + ) + + import api.config as cfg + + config_path = tmp_path / "config.yaml" + config_path.write_text( + """ +model: + default: grok-4.3 + provider: xai-oauth +agent: + reasoning_effort: medium +display: + show_reasoning: true +""".strip(), + encoding="utf-8", + ) + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + + status = cfg.get_reasoning_status() + + assert status["reasoning_effort"] == "medium" + assert status["supported_efforts"] == list(cfg.VALID_REASONING_EFFORTS) + assert status["supports_reasoning_effort"] is True