* fix(models): validate Claude extended thinking budgets * fix(models): preserve Claude thinking budget semantics * fix(models): isolate Claude auto thinking budgets * fix(models): preserve interleaved Claude thinking budgets Consolidate the additional thinking-budget boundary and type coverage from PR #6482 into #6060. Keep copied automatic budgets and preserve valid manual interleaved budgets with supported models, tools, and the effective beta header. Document the validation scope and cover native payload construction and SDK header precedence. * docs(models): avoid upstream Claude setup overlap --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
20 KiB
MindIE XML tool arguments (packages/harness/deerflow/models/mindie_provider.py)
Parse XML tool calls from the original model response before applying escaped-newline
compatibility fixes to the remaining prose. Parse JSON and Python-literal arguments
from their original escapes. For non-JSON raw-string parameters, decode literal
\n outside fenced code and trim surrounding whitespace, retaining the gateway's
existing multiline-file/command behavior. Raw strings cannot distinguish intended
literal escapes from over-escaped newlines. Numeric conversion failures and unsafe
Python-literal containers still retain the entire original argument. Keep the
existing content path when no tool calls can be parsed and preserve native tool
calls. This contract is shared by sync/async generation and tool-enabled simulated
streaming; no-tool native streaming has its own chunk handling. Coverage:
tests/test_mindie_provider.py.
MindIE XML numeric arguments (mindie_provider.py)
The XML parser recognizes JSON numeric syntax, including signed, fractional,
and exponent values. Invalid numeric forms such as 007, 3., and +3 remain
strings. Numeric conversion failures preserve the entire argument, including
lists or objects with overflowing or underflowing floats or oversized integers.
Only JSON syntax errors may use the existing Python-literal fallback; never retry
numeric conversion failures through ast.literal_eval, which can silently turn
nested underflow into zero. Regression coverage is in tests/test_mindie_provider.py.
Model Factory (packages/harness/deerflow/models/factory.py)
Request-admission waits follow the next scheduled admission and configured
interval, capped at 50 ms; the cap must not become a minimum poll interval that
limits high-RPM throughput. Local AdmissionError is structurally non-retriable
in LLM error handling regardless of its message text. Immediate admission and
joining the blocking FIFO are one lock-protected decision: do not split the
fast-path permit check from queue insertion, or an older caller can be overtaken
while handing off to the wait queue.
create_chat_model(name, thinking_enabled)instantiates LLM from config via reflection- Supports
thinking_enabledflag with per-modelwhen_thinking_enabled/when_thinking_disabledoverrides; on both the legacy and the contract path every template and every synthesizedextra_bodypayload goes through_merge_thinking_payload(the native-Anthropic disable assigns the top-levelthinkingmapping outright — a tagged union keyed bytype): keys are never removed (a template can only add or override; nested mappings inherit the profile's other keys), template wins on conflicts, so a template'sextra_bodynever clobbers sibling keys such asextra_body.tool_stream. Corollary: a disable template cannot clear a key the profile sets, so enable-only keys such asextra_body.thinking.budget_tokensbelong inwhen_thinking_enabled, never in the baseextra_body. Templates are deep-copied as they are merged, so constructor kwargs never alias the cachedModelConfig. The vLLM switch has two spellings (_VLLM_THINKING_SWITCHES: legacythinkingalias,enable_thinking); when a profile spells it differently from the payload, the payload's value is mirrored onto the profile's spelling rather than deleting or canonicalizing it —VllmChatModelcollapses the pair toenable_thinking, other OpenAI-compatible classes send both, and either way the server reads the template's intent. Do not canonicalizethinking→enable_thinkingin the factory: chat templates differ on which key they honor, and onlyVllmChatModelopts into that remap - Supports vLLM-style thinking toggles via
when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinkingfor Qwen reasoning models, while normalizing legacythinkingconfigs for backward compatibility - A per-request
reasoning_effortkwarg (the regular, non-bootstrap lead-agent build passes it even whenNone) is popped fromkwargsand layered likemodel_overrides: a non-Nonevalue replaces the profile's, and the thinking transforms applied afterwards (when_thinking_enabled,when_thinking_disabled, theextra_body.thinkingdisable path) still decide the final value. Never let a key reach the constructor through bothkwargsand the profile settings — Python raisesgot multiple values for keyword argumentand the lead agent cannot be built for that model. Codex checks the requested level itself. Pinned bytests/test_model_factory.pyandtests/test_lead_agent_model_resolution.py - Supports
supports_visionflag for image understanding models - Config values starting with
$resolved as environment variables - Missing provider modules surface actionable install hints from reflection resolvers (for example
uv add langchain-google-genai) - Optional
models[].request_admissionattaches a process-sharedBaseRateLimiterat the model factory. Identical explicit groups share one FIFO across model instances, threads and event loops; implicit groups use the configured model name. Policies are immutable once registered and conflicting settings fail construction. A monotonic minimum interval spaces requests without idle-time burst credit; bounded waiters poll without occupying executor threads and unregister infinally. The factory strips the policy from provider kwargs and sets exposed SDKmax_retries=0so middleware retries re-enter admission. This limits model invocations, not tokens or a distributed provider account; custom providers bypassing BaseChatModel hooks are outside the contract. Tests:test_model_request_admission.pyandtest_model_request_admission_fifo_atomic.py.
Reasoning Capability Contract (packages/harness/deerflow/models/reasoning.py)
Mapping-valued ModelConfig.reasoning (issue #5073) is an optional declarative contract beside
the legacy supports_thinking / supports_reasoning_effort booleans: thinking
unsupported | optional | required, on_disable_request (keep_enabled or
reject, required-thinking only), the payload dialect (auto infers it from
when_thinking_enabled), the reasoning history requirement, and an effort
vocabulary with default, generic-value aliases, and a serialization path.
A boolean or level-string reasoning (true / false, or low|medium|high for
gpt-oss style models) remains a native ChatOllama provider setting; the factory
forwards it on the legacy path, and the assembly descriptor keeps it — like a
declared contract's dialect / history — inside model_parameters so
request-affecting reasoning settings move the fingerprint. When the block is
present the booleans are derived from it and contradictory
profiles fail at config load (required + when_thinking_disabled, unsupported
- an enable template, a
defaultoutsidevalues, an explicit boolean that disagrees, an effort value ateffort.pathin the profile or in thewhen_thinking_*/thinkingtemplates that the contract rejects, apaththat is not a dotted identifier or would overwrite a whole mapping, or a stalereasoning_effortkey beside a custom effort path). The factory strips a generic effort key from runtime overrides for custom-path contracts.defaultalso governs callers that never choose an effort (summarization, title, subagents), so shipped profiles keep it below the provider's deepest level.
resolve_reasoning_contract turns any profile (legacy or declared) into an
immutable ReasoningContract, resolve_reasoning_request applies a caller's
generic thinking_enabled / reasoning_effort to it, and
reasoning_capabilities_payload projects it for /api/models and
DeerFlowClient (reasoning object, source: legacy|contract).
create_chat_model is the single enforcement point for every caller (lead agent,
subagents, summarization, title, one-shot utilities): a required-thinking model
never enters the disable branch, effort is mapped through aliases or the default
and otherwise dropped, and dialect synthesizes the on/off payload when no
template exists. Legacy profiles (no mapping-valued reasoning: contract) keep the historical path
byte-for-byte, including the synthesized reasoning_effort=minimal on the
OpenAI-compatible disable path. The lead agent and the subagent descriptor resolve
the same policy first so run metadata reports the effective values. Design note:
docs/plans/2026-09-23-reasoning-capability-contract.md; tests:
tests/test_reasoning_contract.py, the contract section of
tests/test_model_factory.py, tests/test_models_router_reasoning.py.
CLI credential files (packages/harness/deerflow/models/credential_loader.py)
CLI credential JSON uses utf-8-sig for locale-independent reads with optional
BOM. Decode failures follow the existing unreadable-source path, preserving
Claude's override-to-default fallback. Doctor's mirrored JSON reader must accept
the same optional BOM. Tests: test_credential_file_encoding.py.
Codex tool-call/result serialization (packages/harness/deerflow/models/openai_codex_provider.py)
_convert_messages uses _is_valid_call_id for both assistant tool calls
(including invalid_tool_calls) and tool results, omitting empty or
whitespace-only call IDs even when direct provider use bypasses middleware repair.
Non-blank IDs remain byte-for-byte unchanged so calls and results keep their
correlation; serialization does not trim IDs or rewrite the input messages.
Each omitted tool result emits a warning with its normalized content length;
the warning never includes the result content.
Coverage: tests/test_codex_provider.py.
Codex function-tool conversion preserves explicit strict=True and False
for wrapped and flat dictionaries; missing or None stays omitted to preserve
the provider default. Wrapped dictionaries and converted BaseTool schemas
share _convert_tools so binding cannot discard the setting. Keep caller
schemas and definitions unchanged. Offline sync/async request and tool-followup
coverage: tests/test_codex_tool_strict.py.
Codex SSE termination (packages/harness/deerflow/models/openai_codex_provider.py)
Completed responses with null, omitted, or empty usage retain their text,
reasoning, and tool calls. Normalize unavailable usage to the existing empty
mapping fallback while keeping AIMessage.usage_metadata as None; populated
usage mappings, including zero counts and cached/reasoning token details, remain
unchanged.
response.completed ends stream consumption immediately, before transport EOF;
retain the output-item recovery path for empty completed output. Terminal
response.failed, response.incomplete, and error events raise with their
error code/message or incomplete reason, closing the response and client without
returning partial output. Non-object error details or response containers are
reported as text instead of raising AttributeError. SSE failures do not enter
the HTTP-status retry loop.
Offline HTTP-stream coverage: tests/test_codex_stream_terminal_events.py.
Claude Code Credentials (packages/harness/deerflow/models/credential_loader.py)
ClaudeChatModel.model_post_initcallsload_claude_code_credential()for every instance, andcreate_chat_modelbuilds fresh instances per run (lead agent, title, summarization, subagents)$CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTORis a one-shot handoff: a pipe returns EOF and a file keeps its advanced offset._read_secret_from_file_descriptortherefore caches a non-empty secret per(env_var, fd)under a lock held across the read. Do not drop the cache or the lock — later instances would get no credential, and the Anthropic SDK raisesTypeError: Could not resolve authentication methodbefore sending. Empty reads,OSError, and UTF-8 decode failures are not cached; unreadable handoffs returnNoneso the loader can try credential files. Warnings must not include token contents. The key is the descriptor number on purpose — a closed handoff keeps serving its token, and a secret placed on a recycled number in-process is not re-read unless the cache is cleared. The cache is per process, so a new process (e.g. a uvicorn--reloadworker) cannot recover a drained descriptor. Pinned bytests/test_credential_loader.pyandtests/test_claude_fd_encoding.py, including two-instanceClaudeChatModeltests
Claude Prompt Caching (packages/harness/deerflow/models/claude_provider.py)
- The request payload shares objects with the caller: langchain-anthropic forwards Claude-native blocks (an image or document with a
source, search results) and list-form system blocks by reference, and a reused tool binding passes its own tool dicts (the lead agent re-binds per call, so its tool dicts are fresh). Writingcache_controlin place checkpointed the markers with the thread's messages, and the stale ones pushed every later request past the 4-breakpoint limit - Every request goes through
_strip_cache_control, with caching on or off, so markers stored by older checkpoints never reach the API. It copies a marked block without its marker and replaces the system, message, content and tool lists and every message dict with copies; langchain-anthropic already builds fresh message dicts and content lists, so that part is defensive _apply_prompt_cachingmust call_strip_cache_controlfirst: it then replaces slots in those payload-owned lists with marked copies and writesmsg["content"]on copied message dicts, placing at most four breakpoints. Pinned bytests/test_claude_provider_prompt_caching.py- Exclude
thinkingandredacted_thinkingblocks before selecting the last four cache candidates: Anthropic forbids directcache_controlon these blocks. Keep their content, signatures/data and order intact so they remain part of the prefix covered by a later eligible breakpoint. Stripping stale markers must not add them back to thinking blocks or mutate caller-owned history. The same suite exercises real sync/async SDK tool-followup requests through offline transports.
Claude Thinking Budget (packages/harness/deerflow/models/claude_provider.py)
- With
auto_thinking_budget=True, manualthinking.type=enabledrequests validate integer budgets of at least 1024. Ordinary thinking also requiresbudget_tokens < max_tokensand integermax_tokens > 1024; an absent/null budget usesmax(1024, int(max_tokens * 0.8)), defaulting to an 8192 output limit. - Manual interleaving permits a budget equal to or above a positive integer output limit, but only with tools, the effective
interleaved-thinking-2025-05-14beta, and a supported Sonnet 4/4.5/4.6 or Opus 4/4.1/4.5 model (aliases and dated IDs). Haiku 4.5 and Opus 4.6 do not interleave in manual mode even with that header. Follow Anthropic's model-specific rules when updating this capability gate. - Resolve the beta header as the SDK does: client defaults, then request
betas(including an empty list), thenextra_headers. Match comma-separated beta names exactly; a removed or replaced beta must not relax the budget bound. - Automatic allocation replaces the payload's thinking mapping with a copy: LangChain aliases it to
self.thinking, so in-place writes leak across requests and make smaller per-call output limits fail.auto_thinking_budget=Falsebypasses normalization/validation; absent, disabled and adaptive thinking remain untouched. Coverage:tests/test_claude_provider_thinking.py, including native request construction and offline SDK serialization.
vLLM Provider (packages/harness/deerflow/models/vllm_provider.py)
VllmChatModelsubclasseslangchain_openai:ChatOpenAIfor vLLM 0.19.0 OpenAI-compatible endpoints- Preserves vLLM's non-standard assistant
reasoningfield on full responses, streaming deltas, and follow-up tool-call turns, falling back to the legacyreasoning_contentwire field whenreasoningis absent or null (a payload carrying both keepsreasoning);_pick_reasoningowns this precedence across all three paths and preserves empty-stringreasoningrather than falling back - Designed for configs that enable thinking through
extra_body.chat_template_kwargs.enable_thinkingon vLLM 0.19.0 Qwen reasoning models, while accepting the olderthinkingalias cumulative_stream_usageis an opt-in model setting (defaultfalse) for endpoints that repeat cumulative token totals on each streaming chunk. The provider converts snapshots to deltas only when a stable completion id is present, isolates interleaved streams by id, and leaves the original usage untouched otherwise. Per-model tracking is lock-protected and cleared on the trailing empty-choicesframe whether or not that frame carries usage. A soft cap of 1024 ids evicts only entries idle for at least one hour; active streams may temporarily exceed the cap so eviction cannot corrupt their deltas. Regression coverage lives intests/test_vllm_provider.py.
MindIE Provider (packages/harness/deerflow/models/mindie_provider.py)
_fix_messages converts tool results to the XML text format expected by MindIE.
Only tool-message type=json payloads join the text channel; other non-text
blocks remain omitted. Keep the <tool_response> escaping boundary and the
non-serializable/circular JSON fallback. Output limits belong to
ToolOutputBudgetMiddleware, including mixed JSON/media results and configured
exemptions. Enabled PII redaction scans JSON keys and values before serialization.
Regression coverage: test_mindie_provider.py, test_pii_redaction_middleware.py,
and test_tool_output_budget_middleware.py in tests/.
Managed shared models (config/managed_models.py)
ManagedModelStore persists a Fernet-encrypted catalog plus its generated local key
under runtime_home()/managed-models. Files are atomically replaced with temporary
file permissions; complete read/modify/write transactions hold the process lock and
cross-process sidecar lock. Missing keys and invalid catalogs fail closed. Backups
and shared deployments must include both files. SQL storage does not replicate this
catalog. Admin-supplied endpoints can address local providers; only trusted admins
may create or probe them.
get_app_config() and reload_app_config() merge enabled managed models after YAML
profiles, with YAML names winning conflicts. A cached effective snapshot uses the
base config identity and content signatures of both files. Never mutate a previously
returned AppConfig: runtime-scoped and explicitly injected configurations remain
authoritative. _managed_model_names is private source metadata, not provider kwargs.
Direct AppConfig.from_file() continues to read only operator configuration.
config/managed_model_providers.py owns endpoint detection and provider defaults.
Managed profiles normally use langchain_openai:ChatOpenAI. Official HTTPS
api.deepseek.com endpoints (default port, root or /v1 path) instead resolve to
PatchedChatDeepSeek with explicit thinking on/off settings and reasoning-effort
support. Resolve from the parsed endpoint, never a model-name substring; proxies,
lookalike hosts and other paths retain the generic contract. This is derived runtime
configuration: no catalog migration or new API fields. The native api_base field
preserves the administrator's endpoint; the adapter preserves reasoning_content
and sends max_tokens rather than OpenAI's max_completion_tokens.
Full updates require the current revision; omission means create, an omitted API
key retains the saved key and an empty string clears it. Never serialize SecretStr
masking as a saved key. Read APIs return has_api_key, never a credential.
The Gateway probe constructs the resolved class off-loop and applies the profile's
when_thinking_disabled settings to its bounded forced-tool request. It does not
repeat provider selection or persist the probe override.
Tests: test_managed_models.py, test_managed_deepseek.py (real SDK serialization
with an HTTP double), opt-in test_managed_deepseek_live.py, and
tests/blocking_io/test_managed_models.py.