deer-flow/backend/app/gateway/run_models.py
Vanzeren 095092418c
fix(gateway):unify thread id validation (#4589)
* fix(gateway): unify thread ID validation at the API boundary

Thread ID entry points accepted arbitrary strings while downstream
consumers (filesystem paths, Kubernetes Provisioner, JSONL event store)
each enforced different character restrictions, so invalid IDs were
persisted first and only failed later during sandbox/workspace init.

Centralize validation in deerflow.utils.thread_id (pattern
^[A-Za-z0-9_-]{1,64}$): validate at routers, RunCreateRequest,
scheduler dispatch, paths.py, JSONL store, embedded client, and align
the Provisioner pattern (pinned by a parity test). UUIDs are still
generated only when no ID is supplied; caller-supplied opaque IDs stay
supported.

Deliberate exceptions: DELETE /threads/{id} keeps str as the legacy
cleanup escape hatch (filesystem cleanup guarded), read-only
client.get_thread stays unvalidated, and scheduler rows with legacy
invalid IDs record a failed dispatch instead of raising out of the
poll loop.

* docs: document canonical thread ID contract

README: caller-supplied thread IDs need not be UUIDs; the canonical
pattern and per-endpoint behavior. AGENTS.md: the shared
deerflow.utils.thread_id contract, its enforcement boundaries, and the
legacy-ID escape hatches.

* fix(gateway): close thread ID validation gaps at remaining entry points

Follow-up to the canonical thread ID contract: a full audit found the
uniform-422 coverage only reached about half of the thread_id surfaces.

- routers: 18 routes still took a bare thread_id: str — 13 in
  thread_runs.py (including the five messages/events/workspace-changes
  reads that returned 500 on the JSONL event store vs 404/empty on the
  DB store), 4 read routes in threads.py, and the suggestions route
  flagged in review. DELETE /api/threads/{id} keeps str as the declared
  legacy-cleanup escape hatch.
- client: upload_files/delete_upload/list_uploads/get_artifact now
  validate up front, fulfilling the RFC's 'all mutating entry points'
  clause (get_thread stays unvalidated as the declared legacy read path).
- tui: the /resume literal-ref fallback validates against the canonical
  contract and reports a descriptive error instead of failing deep in
  the client.
- scripts/support_bundle.py: replace the drifted dot-allowing pattern
  with a byte-identical copy of THREAD_ID_PATTERN (kept local so the
  script still runs with a broken venv).

* test(gateway): guard the canonical thread ID contract against regressions

- test_thread_id_route_contract.py: static AST sweep asserting every
  route handler with a thread_id parameter annotates ThreadId
  (whitelist: the DELETE escape hatch), plus a runtime sweep hitting
  all 44 thread_id routes with a non-canonical ID and asserting a 422
  that names thread_id, plus a websocket upgrade-rejection case.
- test_thread_id_validation.py: client entry-point validation,
  support_bundle pattern parity, and TUI literal-ref fallback tests.
- Align two tests that encoded the old contract (dotted IDs).
2026-08-01 19:42:44 +08:00

119 lines
5.9 KiB
Python

"""Shared request models for the LangGraph-compatible run boundary."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from pydantic_core import PydanticCustomError
from deerflow.runtime.stream_modes import RunStreamMode, UnsupportedStreamModeError, normalize_stream_modes
from deerflow.utils.thread_id import validate_thread_id
class RunCreateRequest(BaseModel):
"""Validated run request used by both HTTP and internal launch paths."""
model_config = ConfigDict(extra="forbid")
assistant_id: str | None = Field(default=None, description="Agent / assistant to use")
input: dict[str, Any] | None = Field(default=None, description="Graph input (e.g. {messages: [...]})")
command: dict[str, Any] | None = Field(default=None, description="LangGraph Command")
metadata: dict[str, Any] | None = Field(default=None, description="Run metadata")
config: dict[str, Any] | None = Field(default=None, description="RunnableConfig overrides")
context: dict[str, Any] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.)")
webhook: None = Field(default=None, description="Compatibility placeholder; completion callbacks are not supported")
checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint")
checkpoint: dict[str, Any] | None = Field(default=None, description="Full checkpoint object")
interrupt_before: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt before")
interrupt_after: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt after")
stream_mode: list[RunStreamMode] | RunStreamMode | None = Field(default=None, description="Supported stream mode(s)")
stream_subgraphs: bool = Field(default=False, description="Include subgraph events")
stream_resumable: Literal[False] | None = Field(default=None, description="Compatibility placeholder; only the SDK's non-resumable default (null/false) is accepted")
on_disconnect: Literal["cancel", "continue"] = Field(default="cancel", description="Behaviour on SSE disconnect")
on_completion: None = Field(default=None, description="Compatibility placeholder; completion behavior is not supported")
multitask_strategy: Literal["reject", "rollback", "interrupt"] = Field(default="reject", description="Concurrency strategy")
after_seconds: None = Field(default=None, description="Compatibility placeholder; delayed execution is not supported")
if_not_exists: Literal["create"] = Field(default="create", description="Compatibility default; missing threads are created")
feedback_keys: None = Field(default=None, description="Compatibility placeholder; feedback key collection is not supported")
@model_validator(mode="after")
def validate_configurable_thread_id(self) -> RunCreateRequest:
"""Validate the stateless-run thread selector inside RunnableConfig."""
if not isinstance(self.config, dict):
return self
configurable = self.config.get("configurable")
if not isinstance(configurable, dict) or "thread_id" not in configurable:
return self
thread_id = configurable["thread_id"]
if thread_id is not None:
validate_thread_id(thread_id)
return self
@field_validator(
"webhook",
"on_completion",
"multitask_strategy",
"after_seconds",
"if_not_exists",
"feedback_keys",
mode="before",
)
@classmethod
def reject_unsupported_run_options(cls, value: Any, info: ValidationInfo) -> Any:
if info.field_name in {"multitask_strategy", "if_not_exists"} and not isinstance(value, str):
return value
supported_defaults = {
"webhook": None,
"on_completion": None,
"multitask_strategy": {"reject", "rollback", "interrupt"},
"after_seconds": None,
"if_not_exists": "create",
"feedback_keys": None,
}
supported = supported_defaults[info.field_name]
if isinstance(supported, set):
is_supported = isinstance(value, str) and value in supported
else:
is_supported = value == supported
if not is_supported:
raise PydanticCustomError(
"unsupported_run_option",
"Run option '{option}' is not supported by DeerFlow",
{"option": info.field_name},
)
return value
@field_validator("stream_resumable", mode="before")
@classmethod
def reject_resumable_streams(cls, value: Any) -> Any:
# LangGraph SDK clients always send this field (its default is ``False``, which the
# payload's ``None`` filter keeps). ``False`` asks for the non-resumable stream
# DeerFlow already serves, so only an explicit ``True`` requests the unsupported feature.
if value is None or value is False:
return value
raise PydanticCustomError(
"unsupported_run_option",
"Run option '{option}' is not supported by DeerFlow",
{"option": "stream_resumable"},
)
@field_validator("stream_mode", mode="before")
@classmethod
def reject_unsupported_stream_modes(cls, value: Any) -> Any:
if value is None:
return value
if not isinstance(value, str) and (not isinstance(value, list) or not all(isinstance(mode, str) for mode in value)):
return value
try:
normalize_stream_modes(value)
except UnsupportedStreamModeError as exc:
modes = ", ".join(exc.modes)
raise PydanticCustomError(
"unsupported_stream_mode",
"Unsupported stream mode(s): {modes}",
{"modes": modes},
) from exc
return value