deer-flow/backend/tests/test_models_router_reasoning.py
Nan Gao bcaf6db36a
feat(models): add declarative reasoning capability contract (#5780)
* feat(models): add declarative reasoning capability contract (#5073)

Introduce an optional per-model `reasoning:` block beside the legacy
`supports_thinking` / `supports_reasoning_effort` booleans. It declares
thinking availability (unsupported | optional | required), the accepted
effort values with aliases, default, and serialization path, the payload
dialect, and the reasoning-history requirement. When present the booleans
are derived from it and contradictory profiles fail at config load.

`deerflow.models.reasoning` normalizes legacy and declared profiles into
one immutable contract and resolves every caller's generic request against
it. `create_chat_model` is the single enforcement point: required-thinking
models never receive a synthesized disable payload, unsupported effort
values never reach the provider, and the dialect synthesizes the on/off
payload when no template exists. Legacy profiles keep the historical path
byte-for-byte. The lead agent and subagent descriptor resolve the same
policy first so run metadata reports effective values.

`/api/models` and `DeerFlowClient` project the contract as `reasoning`;
the frontend derives mode clamping and effort choices from it through
`core/models/reasoning.ts`, and user preferences accept provider-specific
effort tokens. The setup wizard's Z.AI GLM-5.3-Flash profile migrates to
the contract, restoring its low/high/max effort control.

* docs(models): move the reasoning contract guidance into models/AGENTS.md

backend/AGENTS.md sits within 46 bytes of the 96 KiB effective-chain hard
limit for the middlewares, runtime, sandbox, and subagents guides, so the
new section pushed scripts/check_agent_guidance.py over it. The models
guide is the nearest file for the factory and the contract module, and
its chain has ample room.

* fix(models): address review on the reasoning contract

- vLLM dialect: the enable path now mirrors whichever chat_template_kwargs
  switch the template declares, like the disable path already did, so a
  template using the older `thinking` key no longer also grows
  `enable_thinking`.
- Validate operator-supplied effort values at `effort.path` inside
  `when_thinking_enabled`, `when_thinking_disabled`, and the `thinking`
  shortcut against the contract, not only the profile-level value; they are
  forwarded when the caller chooses nothing, so the "unknown values never
  reach the provider" guarantee now holds for them too.
- Constrain `effort.path` to dotted identifiers and reject single-segment
  paths that would replace a whole mapping (`extra_body`, `thinking`,
  `model_kwargs`, `default_headers`, `default_query`).
- Z.AI GLM-5.3-Flash profile: `default: high` instead of `max`, because the
  default also governs summarization, title generation, and subagent calls
  that never choose an effort; `max` stays selectable. Documented in the
  config example, the configuration manual, and the design note.

* fix(models): close reasoning contract review gaps

* fix(models): preserve native Ollama reasoning config

* fix(models): keep native reasoning strings and project reasoning into the descriptor

- `ModelConfig.reasoning` also accepts the level string ChatOllama takes
  for gpt-oss style models (`low` / `medium` / `high`); the factory forwards
  both the boolean and the string on the legacy path, so profiles that
  passed `reasoning: high` through before the contract field existed keep
  loading and keep sending it.
- The assembly descriptor no longer excludes `reasoning` from
  `model_parameters`: the native ChatOllama value and a declared contract's
  dialect/history/effort change the request payload, so they now move the
  assembly fingerprint.
- e2e: the sidecar spec pinned the pre-contract body where a model without
  effort control still received `reasoning_effort: minimal`; the preset is
  now dropped for such models (the Gateway discarded it anyway), so the
  assertion expects the key to be absent.
2026-09-24 20:39:30 +08:00

94 lines
3.4 KiB
Python

"""``/api/models`` projects the normalized reasoning contract beside the legacy booleans (issue #5073)."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import models as models_router
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.token_usage_config import TokenUsageConfig
def _app_config(models: list[ModelConfig]) -> AppConfig:
return AppConfig(
models=models,
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
token_usage=TokenUsageConfig(enabled=False),
authorization=AuthorizationConfig(),
)
def _legacy_model() -> ModelConfig:
return ModelConfig(name="legacy", model="legacy", use="langchain_openai:ChatOpenAI", supports_thinking=True, supports_reasoning_effort=True)
def _contract_model() -> ModelConfig:
return ModelConfig(
name="glm-5.3-flash",
model="glm-5.3-flash",
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
reasoning={
"thinking": "required",
"dialect": "openai_extra_body",
"history": "clear",
"effort": {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}},
},
)
@pytest.fixture
def client(monkeypatch):
config = AuthorizationConfig(enabled=False)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
monkeypatch.setattr("app.gateway.routers.models.get_optional_user_from_request", AsyncMock(return_value=None))
app = FastAPI()
app.include_router(models_router.router)
app.dependency_overrides[models_router.get_config] = lambda: _app_config([_legacy_model(), _contract_model()])
with TestClient(app) as test_client:
yield test_client
def test_list_models_projects_reasoning_for_every_model(client):
response = client.get("/api/models")
assert response.status_code == 200
by_name = {m["name"]: m for m in response.json()["models"]}
legacy = by_name["legacy"]
assert legacy["supports_thinking"] is True
assert legacy["supports_reasoning_effort"] is True
assert legacy["reasoning"] == {
"thinking": "optional",
"effort": {"values": ["minimal", "low", "medium", "high"], "default": None, "aliases": {}},
"history": None,
"source": "legacy",
}
contract = by_name["glm-5.3-flash"]
# Deprecation-window projection: the booleans are derived from the contract.
assert contract["supports_thinking"] is True
assert contract["supports_reasoning_effort"] is True
assert contract["reasoning"] == {
"thinking": "required",
"effort": {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}},
"history": "clear",
"source": "contract",
}
def test_get_model_projects_reasoning(client):
response = client.get("/api/models/glm-5.3-flash")
assert response.status_code == 200
body = response.json()
assert body["reasoning"]["thinking"] == "required"
assert body["reasoning"]["effort"]["values"] == ["low", "high", "max"]