182 lines
5.6 KiB
Python
182 lines
5.6 KiB
Python
"""Admin-only model-stream truncation event log (模型截断记录).
|
|
|
|
Companion to the per-model ``strict_stream_integrity`` switch on the model
|
|
management page: the patched OpenAI-compatible streaming layer writes one row
|
|
per abnormally ended provider stream (silently committed half answers, strict
|
|
integrity detections, output-length caps, mid-stream transport errors). This
|
|
router serves the admin viewing page; rows are retained for at most one month
|
|
by the SQL repository.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.deps import get_optional_user_from_request, get_stream_truncation_store
|
|
from deerflow.persistence.stream_truncation.base import StreamTruncationQuery, StreamTruncationStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/admin/stream-truncations", tags=["stream-truncations"])
|
|
|
|
_EVENT_TYPES = (
|
|
"silent_no_terminal",
|
|
"incomplete_stream_error",
|
|
"length_truncated",
|
|
"content_filter",
|
|
"midstream_error",
|
|
)
|
|
|
|
|
|
class StreamTruncationEventResponse(BaseModel):
|
|
id: str
|
|
created_at: str | None = None
|
|
model_name: str | None = None
|
|
provider_completion_id: str | None = None
|
|
run_id: str | None = None
|
|
event_type: str
|
|
strict_mode: bool = False
|
|
finish_reason: str = "none"
|
|
status: str = "success"
|
|
error_type: str | None = None
|
|
chunk_count: int = 0
|
|
duration_ms: int = 0
|
|
first_chunk_ms: int = -1
|
|
detail: str | None = None
|
|
|
|
|
|
class StreamTruncationListResponse(BaseModel):
|
|
items: list[StreamTruncationEventResponse]
|
|
total: int = Field(..., description="Total rows matching the filter, ignoring pagination")
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class StreamTruncationBreakdownItem(BaseModel):
|
|
model_name: str
|
|
total: int = 0
|
|
last_at: str | None = None
|
|
silent_no_terminal: int = 0
|
|
incomplete_stream_error: int = 0
|
|
length_truncated: int = 0
|
|
content_filter: int = 0
|
|
midstream_error: int = 0
|
|
|
|
|
|
class StreamTruncationBreakdownResponse(BaseModel):
|
|
items: list[StreamTruncationBreakdownItem]
|
|
|
|
|
|
async def _require_admin(request: Request) -> None:
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="Authentication is required")
|
|
if getattr(user, "system_role", None) != "admin":
|
|
raise HTTPException(status_code=403, detail="Stream truncation log is admin-only")
|
|
|
|
|
|
def _require_store(request: Request) -> StreamTruncationStore:
|
|
store = get_stream_truncation_store(request)
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="Stream truncation store is not configured")
|
|
return store
|
|
|
|
|
|
def _parse_dt(value: str | datetime | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid datetime: {value!r} ({exc})") from exc
|
|
|
|
|
|
def _build_query(
|
|
*,
|
|
model_name: str | None,
|
|
event_type: str | None,
|
|
since: str | datetime | None,
|
|
until: str | datetime | None,
|
|
limit: int,
|
|
offset: int,
|
|
) -> StreamTruncationQuery:
|
|
if event_type and event_type not in _EVENT_TYPES:
|
|
raise HTTPException(status_code=422, detail=f"Unknown event type: {event_type}")
|
|
return StreamTruncationQuery(
|
|
model_name=model_name or None,
|
|
event_type=event_type or None,
|
|
since=_parse_dt(since),
|
|
until=_parse_dt(until),
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
def _fill_breakdown_zeros(item: dict[str, Any]) -> dict[str, Any]:
|
|
for event_type in _EVENT_TYPES:
|
|
item.setdefault(event_type, 0)
|
|
return item
|
|
|
|
|
|
@router.get("", response_model=StreamTruncationListResponse)
|
|
async def list_stream_truncations(
|
|
request: Request,
|
|
model_name: str | None = Query(default=None, max_length=128),
|
|
event_type: str | None = Query(default=None, max_length=32),
|
|
since: str | None = Query(default=None),
|
|
until: str | None = Query(default=None),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> StreamTruncationListResponse:
|
|
"""List truncation events, newest first."""
|
|
await _require_admin(request)
|
|
store = _require_store(request)
|
|
query = _build_query(
|
|
model_name=model_name,
|
|
event_type=event_type,
|
|
since=since,
|
|
until=until,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
items = await store.list(query)
|
|
total = await store.count(query)
|
|
return StreamTruncationListResponse(
|
|
items=[StreamTruncationEventResponse(**item) for item in items],
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
@router.get("/breakdown", response_model=StreamTruncationBreakdownResponse)
|
|
async def stream_truncation_breakdown(
|
|
request: Request,
|
|
since: str | None = Query(default=None),
|
|
until: str | None = Query(default=None),
|
|
) -> StreamTruncationBreakdownResponse:
|
|
"""Per-model aggregate of truncation events over the query window."""
|
|
await _require_admin(request)
|
|
store = _require_store(request)
|
|
query = _build_query(
|
|
model_name=None,
|
|
event_type=None,
|
|
since=since,
|
|
until=until,
|
|
limit=1,
|
|
offset=0,
|
|
)
|
|
rows = await store.model_breakdown(query)
|
|
return StreamTruncationBreakdownResponse(
|
|
items=[
|
|
StreamTruncationBreakdownItem(**_fill_breakdown_zeros(item))
|
|
for item in rows
|
|
]
|
|
)
|