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

218 lines
8.2 KiB
Python

"""Normalized model reasoning capability contract (issue #5073).
Every model creation path — the lead agent, subagents, summarization, title
generation, one-shot utilities — used to interpret ``supports_thinking`` /
``supports_reasoning_effort`` on its own and then hand generic
``thinking_enabled`` / ``reasoning_effort`` values to the factory. That could
not describe models whose provider contract differs from the generic
assumptions (required thinking, restricted effort vocabularies, provider
specific payload dialects).
This module owns the single normalized view:
* :func:`resolve_reasoning_contract` turns a :class:`ModelConfig` (or any
duck-typed profile) into an immutable :class:`ReasoningContract`. Profiles
that declare ``reasoning:`` map 1:1; legacy profiles derive the contract from
the booleans and keep their historical, non-strict behavior.
* :func:`resolve_reasoning_request` applies a caller's generic request to the
contract and returns the effective ``thinking_enabled`` and provider effort
value. It is the one policy the factory and the callers share.
* :func:`reasoning_capabilities_payload` projects the contract for the
Gateway ``/api/models`` response and ``DeerFlowClient``.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Literal
from deerflow.config.model_config import ReasoningCapabilities
ThinkingMode = Literal["unsupported", "optional", "required"]
DisableRequestPolicy = Literal["keep_enabled", "reject"]
ReasoningDialect = Literal["auto", "openai_extra_body", "anthropic", "vllm_chat_template", "ollama", "none"]
ReasoningHistory = Literal["preserve", "clear"]
ContractSource = Literal["legacy", "contract"]
#: The vocabulary DeerFlow's generic UI and per-agent defaults emit. Legacy
#: profiles advertise exactly this set so the frontend keeps its old choices.
GENERIC_EFFORT_VALUES: tuple[str, ...] = ("minimal", "low", "medium", "high")
#: Constructor keyword the OpenAI-compatible clients accept for effort.
DEFAULT_EFFORT_PATH = "reasoning_effort"
class ReasoningPolicyError(ValueError):
"""A request contradicts the model's reasoning contract and must not reach the provider."""
@dataclass(frozen=True)
class EffortContract:
"""Allowed effort values for one model.
``strict`` distinguishes a declared contract (unknown values never reach the
provider) from the legacy projection (values are forwarded verbatim, as
they always were).
"""
values: tuple[str, ...]
default: str | None = None
aliases: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
path: str = DEFAULT_EFFORT_PATH
strict: bool = True
@dataclass(frozen=True)
class ReasoningContract:
"""Normalized reasoning capabilities of one model profile."""
thinking: ThinkingMode
effort: EffortContract | None
on_disable_request: DisableRequestPolicy = "keep_enabled"
dialect: ReasoningDialect = "auto"
history: ReasoningHistory | None = None
source: ContractSource = "legacy"
@property
def supports_thinking(self) -> bool:
return self.thinking != "unsupported"
@property
def thinking_required(self) -> bool:
return self.thinking == "required"
@property
def supports_reasoning_effort(self) -> bool:
return self.effort is not None
@dataclass(frozen=True)
class ResolvedReasoning:
"""The effective policy for one model call.
``adjustments`` lists what the contract changed about the request so
callers can log it: ``thinking_unsupported``, ``thinking_forced_on``,
``effort_unsupported``, ``effort_aliased``, ``effort_unsupported_value``.
"""
thinking_enabled: bool
reasoning_effort: str | None
adjustments: tuple[str, ...] = ()
def resolve_reasoning_contract(model_config: Any) -> ReasoningContract:
"""Normalize a model profile into a :class:`ReasoningContract`.
Accepts a real :class:`ModelConfig` as well as duck-typed stand-ins (the
subagent descriptor builder and several tests pass ``SimpleNamespace`` or
mocks). Only a genuine :class:`ReasoningCapabilities` instance counts as a
declared contract; anything else falls back to the legacy booleans.
"""
declared = getattr(model_config, "reasoning", None)
if isinstance(declared, ReasoningCapabilities):
effort = None
if declared.effort is not None:
effort = EffortContract(
values=tuple(declared.effort.values),
default=declared.effort.default,
aliases=MappingProxyType(dict(declared.effort.aliases)),
path=declared.effort.path,
strict=True,
)
return ReasoningContract(
thinking=declared.thinking,
effort=effort,
on_disable_request=declared.on_disable_request,
dialect=declared.dialect,
history=declared.history,
source="contract",
)
supports_thinking = bool(getattr(model_config, "supports_thinking", False))
supports_effort = bool(getattr(model_config, "supports_reasoning_effort", False))
return ReasoningContract(
thinking="optional" if supports_thinking else "unsupported",
effort=EffortContract(values=GENERIC_EFFORT_VALUES, strict=False) if supports_effort else None,
source="legacy",
)
def resolve_reasoning_request(contract: ReasoningContract, *, thinking_enabled: bool, reasoning_effort: str | None) -> ResolvedReasoning:
"""Apply a generic request to the contract.
Raises:
ReasoningPolicyError: the model requires thinking, the caller asked for
it to be off, and the profile chose ``on_disable_request: reject``.
"""
adjustments: list[str] = []
if contract.thinking == "unsupported":
if thinking_enabled:
adjustments.append("thinking_unsupported")
effective_thinking = False
elif contract.thinking == "required":
if not thinking_enabled:
if contract.on_disable_request == "reject":
raise ReasoningPolicyError("model requires thinking, but the request asked for it to be disabled")
adjustments.append("thinking_forced_on")
effective_thinking = True
else:
effective_thinking = bool(thinking_enabled)
effort = contract.effort
if effort is None:
if reasoning_effort is not None:
adjustments.append("effort_unsupported")
effective_effort: str | None = None
elif not effort.strict:
effective_effort = reasoning_effort
elif reasoning_effort is None:
effective_effort = effort.default
elif not isinstance(reasoning_effort, str):
# Only string tokens can be matched against a vocabulary; anything else
# is an invalid request and falls back like an unknown value.
adjustments.append("effort_unsupported_value")
effective_effort = effort.default
elif reasoning_effort in effort.values:
effective_effort = reasoning_effort
elif reasoning_effort in effort.aliases:
adjustments.append("effort_aliased")
effective_effort = effort.aliases[reasoning_effort]
else:
adjustments.append("effort_unsupported_value")
effective_effort = effort.default
return ResolvedReasoning(thinking_enabled=effective_thinking, reasoning_effort=effective_effort, adjustments=tuple(adjustments))
def reasoning_capabilities_payload(contract: ReasoningContract) -> dict[str, Any]:
"""JSON-shaped projection of the contract for the Gateway API and the client."""
effort: dict[str, Any] | None = None
if contract.effort is not None:
effort = {
"values": list(contract.effort.values),
"default": contract.effort.default,
"aliases": dict(contract.effort.aliases),
}
return {
"thinking": contract.thinking,
"effort": effort,
"history": contract.history,
"source": contract.source,
}
__all__ = [
"DEFAULT_EFFORT_PATH",
"GENERIC_EFFORT_VALUES",
"EffortContract",
"ReasoningContract",
"ReasoningPolicyError",
"ResolvedReasoning",
"reasoning_capabilities_payload",
"resolve_reasoning_contract",
"resolve_reasoning_request",
]