mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* test: stabilize reporter evidence and guard missing chat nodes * feat(models): scope Web model choices to tasks, turns and conversations * test(e2e): mock model-control preview for ordinary chat submissions * fix(models): harden scoped choices and stale Agent submissions --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
145 lines
7.2 KiB
Python
145 lines
7.2 KiB
Python
"""Resolve direct user model choices without calling a classifier or modifying state.
|
||
|
||
Only the caller's visible catalog supplies candidates. This deliberately handles
|
||
direct controls, not model mentions in documents, quotations or conditional text.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any, Literal
|
||
|
||
ModelScope = Literal["task", "turn", "conversation"]
|
||
|
||
_PATTERNS = (
|
||
("conversation", r"^(?:请)?(?:以后|今后|接下来)(?:这个对话|这段对话|这里)?(?:都|一直|默认)?(?:用|使用|改用|换成)\s*(.+?)(?:模型)?(?:[,。::]|$)"),
|
||
("conversation", r"^(?:please\s+)?from now on\s+use\s+(.+?)(?:[,:]|$)"),
|
||
("task", r"^(?:请|麻烦)?你?(?:找|让|叫)\s*(.+?)\s*(?:去|来)?(?:处理|做|解决|分析|计算|回答|帮我)"),
|
||
("task", r"^(?:这个|这部分|这件事|这个任务).{0,16}?(?:交给|让)\s*(.+?)\s*(?:去|来)?(?:处理|做|解决|分析|计算|回答)"),
|
||
("task", r"^(?:please\s+)?(?:ask|delegate to)\s+(.+?)\s+to\s+"),
|
||
("turn", r"^(?:请)?(?:这次|本次|这一轮)(?:请)?(?:用|使用|改用|换成)\s*(.+?)(?:\s*(?:模型)?(?P<action>帮我|来|去|处理|计算|回答|分析|生成|写|做|[,::])|$)"),
|
||
("turn", r"^(?:please\s+)?use\s+(.+?)\s+for this\s+(?:turn|task)\b"),
|
||
(None, r"^(?:请)?(?:用|使用|改用|换成)\s*(.+?)(?:\s*(?:模型)?(?P<action>帮我|来|去|处理|计算|回答|分析|生成|写|做|[,::])|$)"),
|
||
(None, r"^(?:please\s+)?use\s+(.+?)\s+to\s+"),
|
||
)
|
||
|
||
|
||
def _tokens(value: str) -> list[str]:
|
||
# Keep decimal versions intact: 4.1 must not accidentally match 4.10.
|
||
return [part for part in re.findall(r"[a-z]+|\d+(?:\.\d+)*|[\u4e00-\u9fff]+", value.casefold()) if part != "v"]
|
||
|
||
|
||
def _spellings(model: Any) -> list[str]:
|
||
return [value for value in (model.name, model.display_name, model.model, *getattr(model, "aliases", ())) if isinstance(value, str) and value.strip()]
|
||
|
||
|
||
def _matches(query: str, model: Any) -> bool:
|
||
wanted = _tokens(query)
|
||
if not wanted:
|
||
return False
|
||
for spelling in _spellings(model):
|
||
available = _tokens(spelling)
|
||
# CamelCase names such as DeepSeek also admit their initials (ds).
|
||
for word in re.findall(r"[A-Za-z]+", spelling):
|
||
capitals = "".join(re.findall(r"[A-Z]", word))
|
||
if len(capitals) > 1:
|
||
available.append(capitals.casefold())
|
||
if all(any(token == part if token[0].isdigit() else part.startswith(token) for part in available) for token in wanted):
|
||
return True
|
||
return False
|
||
|
||
|
||
def resolve_model_control(text: str, models: list[Any], *, model_name: str | None = None, scope: ModelScope | None = None) -> dict[str, Any]:
|
||
"""Return a read-only decision; explicit selections are checked afresh.
|
||
|
||
The host must enforce model:use before execution. Visibility is not permission
|
||
to use a model. Text must be the user's direct input, never attachment text.
|
||
"""
|
||
result: dict[str, Any] = {"status": "none", "scope": None, "query": "", "model_name": None, "candidates": [], "task_offset": 0}
|
||
if len(text) > 4000 or "\n" in text.split(":", 1)[0].split(":", 1)[0] or text.lstrip().startswith(("`", '"', "“", "'")):
|
||
return result
|
||
for found_scope, pattern in _PATTERNS:
|
||
match = re.match(pattern, text.strip(), re.IGNORECASE)
|
||
if match:
|
||
query = match.group(1).strip()
|
||
break
|
||
else:
|
||
return result
|
||
if model_name is not None:
|
||
candidates = [model for model in models if model.name == model_name]
|
||
if not candidates:
|
||
candidates = [model for model in models if _matches(model_name, model)]
|
||
else:
|
||
candidates = [model for model in models if _matches(query, model)]
|
||
# "Use Python to ..." is an ordinary task. Unknown versioned or explicitly
|
||
# named models, however, must not fall through to a misleading model reply.
|
||
if not candidates and model_name is None and not re.search(r"\bv\d|模型|\bmodel\b", query, re.IGNORECASE):
|
||
return result
|
||
offset = len(text) - len(text.lstrip()) + match.end()
|
||
if match.groupdict().get("action") in {"处理", "计算", "回答", "分析", "生成", "写", "做"}:
|
||
offset = len(text) - len(text.lstrip()) + match.start("action")
|
||
while offset < len(text) and (text[offset].isspace() or text[offset] in ",。::,"):
|
||
offset += 1
|
||
result.update(scope=scope or found_scope, query=query, candidates=[{"name": m.name, "label": m.display_name or m.name} for m in candidates], task_offset=offset)
|
||
if not candidates:
|
||
result["status"] = "unavailable"
|
||
elif len(candidates) != 1 or result["scope"] is None:
|
||
result["status"] = "ambiguous"
|
||
else:
|
||
result.update(status="resolved", model_name=candidates[0].name)
|
||
return result
|
||
|
||
|
||
def authorize_selected_model(model_name: str, *, context: Any, app_config: Any) -> None:
|
||
"""Check an explicit choice without the lead agent's default substitution.
|
||
|
||
Run off-loop: provider resolution and authorization can perform blocking I/O.
|
||
Explicit denials reject; provider failures retain the deployment's policy.
|
||
"""
|
||
if app_config.get_model_config(model_name) is None:
|
||
raise ValueError("The selected model is not configured.")
|
||
authz = getattr(app_config, "authorization", None)
|
||
if authz is None or authz.enabled is not True:
|
||
return
|
||
from deerflow.authz.principal import build_principal_from_context
|
||
from deerflow.authz.provider import AuthzDecision, AuthzRequest
|
||
from deerflow.authz.runtime import resolve_authorization_provider
|
||
|
||
try:
|
||
provider = resolve_authorization_provider(authz)
|
||
if provider is None:
|
||
raise RuntimeError("Authorization provider unavailable")
|
||
principal = build_principal_from_context(context, default_role=authz.default_role)
|
||
decision = provider.authorize(AuthzRequest(principal=principal, resource="model", action="use", target=model_name))
|
||
if not isinstance(decision, AuthzDecision):
|
||
raise TypeError("Invalid authorization result")
|
||
except Exception:
|
||
if authz.fail_closed:
|
||
raise ValueError("Model authorization is unavailable.") from None
|
||
return
|
||
if not decision.allow:
|
||
raise ValueError("The selected model is not available for your role.")
|
||
|
||
|
||
def bind_selected_task_model(tools: list[Any], model_name: str) -> list[Any]:
|
||
"""Expose this run's resolved choice in the task parameter, not a prompt.
|
||
|
||
Clone both schema and tool: parallel runs must never rewrite the singleton.
|
||
No unfiltered model catalog or provider configuration is exposed.
|
||
"""
|
||
from pydantic import Field, create_model
|
||
|
||
selected = []
|
||
for tool in tools:
|
||
schema = getattr(tool, "args_schema", None)
|
||
if tool.name != "task" or not isinstance(schema, type) or "model_name" not in schema.model_fields:
|
||
selected.append(tool)
|
||
continue
|
||
selected_schema = create_model(
|
||
"SelectedTaskModel",
|
||
__base__=schema,
|
||
model_name=(Literal[model_name] | None, Field(default=model_name, description="The user's selected model for this child task; the parent model is unchanged.")),
|
||
)
|
||
selected.append(tool.model_copy(update={"args_schema": selected_schema}))
|
||
return selected
|