deer-flow/backend/app/gateway/routers/managed_models.py
yetuge 7eb41cfc93
refactor(gateway): hoist the persistent-write drain into one shared helper (#6151)
* refactor(gateway): hoist the persistent-write drain into one shared helper

Each persistent-write surface forked its own per-router drain wrapper:
_drained_write in routers/agents.py (action label, expected_errors
pass-through, lost-failure type-only logging), _run_store_mutation in
routers/subagents.py (bare drain), and the inline await_drained call in
routers/managed_models.py. Hoist the superset into
app.gateway.persistent_writes.run_drained_write and point all three
routers at it, per the review suggestion on #6087 (its family
predecessors #6078/#6092/#6093 have since landed).

The subagents and managed-models call sites now also emit the type-only
lost-failure log line on a drained-then-failed write (previously
silent), matching the tradeoff acknowledged in the #6087 review.
artifacts.py and personal_mcp.py keep their direct await_drained forms:
the former drains a coroutine with its own rollback flow, the latter
needs no label or expected-error handling.

Router tests move their monkeypatch targets to the shared name; the
lost-failure assertion pins the helper's logger. AGENTS.md points the
next persistent-write surface at the canonical implementation.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(gateway): quiet expected domain errors and scope the AGENTS.md drain claim

Review follow-ups on #6151:

- The subagents create/update call sites pass (ManagedSubagentExistsError,)
  and (FileNotFoundError,) so the shared helper stays silent on routine
  4xx outcomes (duplicate-name 409, stale-store 404 race), matching
  create_agent's (AgentExistsError,).
- The managed-models save passes (HTTPException,), so the helper stops
  double-logging failures that _save_with_failure_logging already
  reports and stays silent on its routine 4xx outcomes.
- The AGENTS.md sentence now names the three converted routers instead
  of implying every persistent-write surface uses the helper; the
  shortened form also keeps the file under the guidance byte budget
  that agent-guidance enforces.

Signed-off-by: yetuge <2219677952@qq.com>

---------

Signed-off-by: yetuge <2219677952@qq.com>
2026-10-01 20:13:57 +08:00

121 lines
5.4 KiB
Python

"""Admin-only shared model management. Credentials never leave the server."""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Request
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict
from app.gateway.deps import require_admin_user
from app.gateway.persistent_writes import run_drained_write
from deerflow.config.app_config import get_app_config
from deerflow.config.managed_models import ManagedModel, ManagedModelStore
from deerflow.reflection import resolve_class
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/managed-models", tags=["models"])
_ADMIN = "Admin privileges are required to manage shared models."
class SaveModelRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
config: ManagedModel
expected_revision: str | None = None
def _catalog():
config = get_app_config()
yaml_models = [item for item in config.models if item.name not in config._managed_model_names]
yaml_names = {item.name for item in yaml_models}
return {
"models": [{"name": item.name, "display_name": item.display_name or item.name, "model": item.model, "source": "config", "enabled": True} for item in yaml_models]
+ [{**item.public(), "conflict": item.name in yaml_names} for item in ManagedModelStore().list()]
}
@router.get("")
async def list_managed_models(request: Request):
await require_admin_user(request, detail=_ADMIN)
try:
return await asyncio.to_thread(_catalog)
except ValueError:
raise HTTPException(503, "Managed model storage is unavailable; check the catalog and encryption key") from None
def _save(body: SaveModelRequest):
config = get_app_config()
if any(item.name == body.config.name and item.name not in config._managed_model_names for item in config.models):
raise HTTPException(409, "This model name is reserved by config.yaml")
try:
return ManagedModelStore().save(body.config, expected_revision=body.expected_revision).public()
except FileExistsError:
raise HTTPException(409, "Model changed or already exists; reload before saving") from None
except FileNotFoundError:
raise HTTPException(404, "Model no longer exists") from None
except (ValueError, OSError):
raise HTTPException(503, "Managed model storage is unavailable") from None
def _save_with_failure_logging(body: SaveModelRequest):
"""Record save failures before a cancelled caller consumes the worker result."""
try:
return _save(body)
except HTTPException as exc:
if exc.status_code >= 500:
logger.error("Managed model save failed (HTTP %d)", exc.status_code)
raise
except Exception as exc:
# Storage exceptions can contain credentials; retain only the type.
logger.error("Managed model save failed (%s)", type(exc).__name__)
raise
@router.put("")
async def save_model(request: Request, body: SaveModelRequest):
await require_admin_user(request, detail=_ADMIN)
return await run_drained_write("Save managed model", _save_with_failure_logging, (HTTPException,), body)
def _probe_config(body: SaveModelRequest):
profile = body.config
if body.expected_revision is not None:
previous = next((item for item in ManagedModelStore().list() if item.name == profile.name), None)
if previous is None or previous.revision != body.expected_revision:
raise HTTPException(409, "Model changed; reload before testing")
if profile.api_key is None:
profile = profile.model_copy(update={"api_key": previous.api_key})
return profile.runtime_config()
def _build_probe(body: SaveModelRequest):
"""Resolve credentials and construct provider clients off the event loop."""
config = _probe_config(body)
settings = config.model_dump(include={"model", "api_key", "base_url", "api_base"}, exclude_none=True)
# Forced tool selection requires thinking off on DeepSeek. Reuse the resolved
# profile's disable settings without changing the saved chat configuration.
settings.update(config.when_thinking_disabled or {})
settings.update(timeout=15, max_retries=0, max_tokens=32)
model = resolve_class(config.use)(**settings)
return model.bind_tools([{"type": "function", "function": {"name": "connection_check", "description": "Check the connection", "parameters": {"type": "object", "properties": {}}}}], tool_choice="connection_check")
@router.post("/test")
async def test_model(request: Request, body: SaveModelRequest):
"""Send a bounded streaming tool-call probe without saving the profile."""
await require_admin_user(request, detail=_ADMIN)
try:
probe = await asyncio.to_thread(_build_probe, body)
response = None
async with asyncio.timeout(20):
async for chunk in probe.astream([HumanMessage(content="Call connection_check.")], config={"callbacks": []}):
response = chunk if response is None else response + chunk
if response is None or not any(call["name"] == "connection_check" and call["args"] == {} for call in response.tool_calls):
return {"ok": False, "message": "tool_call_missing"}
return {"ok": True, "message": "success"}
except HTTPException:
raise
except Exception:
# Provider exception strings may contain keys, URLs or response bodies.
return {"ok": False, "message": "connection_failed"}