mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
* fix(gateway): preserve owner isolation when thread metadata is missing Follow-up to the #5448 review P1 (post-merge finding): owner_check=True also authorizes threads whose meta row is missing (legacy compatibility) or NULL-owner (shared/pre-auth data). _run_scope_user_id returned None for every trusted internal caller, which dropped the only remaining per-user filter on those threads and let an internal caller acting for owner A read owner B's persisted runs. _run_scope_user_id now takes the thread_id and consults the thread meta store: when an existing meta row establishes ownership, the authorized thread's runs are still read unfiltered (merged #5448 semantics, including owner-header-less internal callers); when the meta row is missing or NULL-owner, the filter falls back to the acting owner's raw stamp (the exact value start_run writes) — or the synthetic "default" identity without an owner header — so cross-user runs stay hidden. Isolation coverage uses the real MemoryThreadMetaStore with no metadata row (and a NULL-owner row) plus another user's persisted run: /runs and /runs/page must be empty and /runs/{run_id} must 404 for internal callers, while an established-ownership thread keeps the unfiltered read. * fix(gateway): gate run-scoped sub-resource reads for internal callers Review follow-up on #5484: the P1 owner-isolation class remained reachable through run-scoped sibling reads that apply no per-user filter at all — /runs/{run_id}/messages, /events, /join, /stream and /workspace-changes query by (thread_id, run_id) directly, so on missing/NULL-owner threads an internal caller acting for owner A could still read owner B's run content by id (verified 200 at the previous head). - Extract _thread_ownership_established (shared meta-row check) and add _require_run_visible_to_scope: for internal callers on threads without established ownership, the run's own user_id stamp must match the acting owner's raw value (or the legacy "default" stamp) or the read 404s. Established-ownership threads and every non-internal caller keep their existing thread-scoped semantics. - Wire the gate into join, stream, messages, events and workspace-changes; reword the now-stale messages comment to track the new scoping semantics. Regression tests: sub-resource reads 404 for a mismatched internal owner while the matching owner reads them normally, and the owner-less fallback branch (synthetic "default" filter on missing-meta threads) is pinned. Red confirmed against the pre-gate head. * fix(gateway): gate cancel and artifact archive for internal callers Review follow-up on #5484 round 2: POST /cancel resolved runs unscoped (require_existing=True only closes the missing-meta case — NULL-owner meta rows still pass), so an internal caller acting for a different owner could interrupt another owner's active run on a shared thread while /join and /stream were already gated. The archive manifest and download pair likewise leaked the other owner's delivered-file count and a 200-vs-409 delivery oracle on NULL-owner threads (missing-meta threads were already denied by require_existing=True). All three routes now call _require_run_visible_to_scope; its docstring records the extended coverage. NULL-owner-thread regression tests pin: a mismatched internal owner gets 404 from cancel, manifest and archive download, while the acting owner reaches the real conflict path (409 on a terminal run) and reads the manifest (file_count 2). * fix(gateway): tolerate state-less request stand-ins in the scope helpers The new owner-isolation gate and _run_scope_user_id read request.state directly, which crashed the FakeRequest-based unit suites for the run events, workspace-changes and scope endpoints (backend-unit-tests shards 1/2/4 on #5484). Read the state object defensively first: a request without state is simply not an internal caller, so those paths keep their pre-gate semantics. * fix(gateway): scope the thread token-usage aggregate by owner Review follow-up on #5484 round 4: GET /{thread_id}/token-usage called aggregate_tokens_by_thread(thread_id) with no user filter at all, so on missing/NULL-owner threads an internal caller acting for owner A read owner B's spend, model names, run count and (with include_active=true) live activity; the NULL-owner variant reached browser sessions too. build_context_usage's latest-model lookup was unfiltered as well. aggregate_tokens_by_thread gains an optional user_id (mirroring list_by_thread: explicit None = unfiltered, AUTO resolves the contextvar) in the memory store, the SQL repository and the store base; build_context_usage/_resolve_thread_model_name thread the scope through the latest-run lookup; the token-usage endpoint passes _run_scope_user_id's value. Established-ownership threads aggregate unfiltered as before; shared/missing-meta threads narrow to the acting identity. Stale helper-test comment reworded after the #5482 merge adaptation. * test(gateway): pin the unfiltered aggregate on established-ownership threads Review follow-up on #5484 round 5: the established-ownership branch of the token-usage scoping (store receives user_id=None) was the only unpinned half of the contract — the round-4 call-assertions never set app.state.thread_store, so their None came from the user-less stand-in path. test_token_usage_unfiltered_on_established_ownership_for_ internal_callers seeds an established meta row plus runs stamped by two different identities and asserts the totals fold (166 = 111 + 55); together with the isolation tests it now catches both failure modes (always-stamp narrowing and always-None leak).
91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
"""Compute the current message-context usage for a thread."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.services import build_thread_checkpoint_state_accessor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _count_messages_approximately(messages: list[Any]) -> int:
|
|
"""Count checkpoint messages with LangChain's network-free heuristic."""
|
|
if not messages:
|
|
return 0
|
|
from langchain_core.messages.utils import count_tokens_approximately
|
|
|
|
return int(count_tokens_approximately(messages))
|
|
|
|
|
|
async def _load_checkpoint_messages(accessor: Any, config: dict[str, Any]) -> list[Any]:
|
|
"""Read materialized messages so full and delta checkpoints behave alike."""
|
|
snapshot = await accessor.aget(config)
|
|
values = getattr(snapshot, "values", None) or {}
|
|
if not isinstance(values, dict):
|
|
return []
|
|
return list(values.get("messages") or [])
|
|
|
|
|
|
async def _resolve_thread_model_name(run_store: Any, thread_id: str, app_config: Any, user_id: str | None = None) -> str | None:
|
|
"""Prefer the latest run's model, then fall back to the first configured model.
|
|
|
|
``user_id`` scopes the latest-run lookup (``None`` = unfiltered, matching
|
|
the thread-scoped aggregate semantics).
|
|
"""
|
|
try:
|
|
runs = await run_store.list_by_thread(thread_id, limit=1, user_id=user_id)
|
|
except Exception:
|
|
runs = []
|
|
if runs:
|
|
latest = runs[0]
|
|
name = latest.get("model_name") if isinstance(latest, dict) else getattr(latest, "model_name", None)
|
|
if isinstance(name, str) and name:
|
|
return name
|
|
models = getattr(app_config, "models", None) or []
|
|
return models[0].name if models else None
|
|
|
|
|
|
def build_context_usage_payload(*, token_count: int, max_context_tokens: int | None) -> dict[str, Any]:
|
|
"""Build the stable API payload for a message count and model capacity."""
|
|
percentage: float | None = None
|
|
if max_context_tokens and max_context_tokens > 0:
|
|
percentage = round(token_count / max_context_tokens * 100, 1)
|
|
return {
|
|
"token_count": token_count,
|
|
"max_context_tokens": max_context_tokens,
|
|
"percentage": percentage,
|
|
}
|
|
|
|
|
|
async def build_context_usage(request: Request, thread_id: str, run_store: Any, user_id: str | None = None) -> dict[str, Any] | None:
|
|
"""Return approximate usage for the latest materialized thread checkpoint."""
|
|
try:
|
|
app_config = get_config()
|
|
except HTTPException:
|
|
return None
|
|
|
|
try:
|
|
accessor, checkpoint_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
|
|
messages = await _load_checkpoint_messages(accessor, checkpoint_config)
|
|
except Exception:
|
|
logger.warning("Failed to load checkpoint for context usage on thread %s", thread_id, exc_info=True)
|
|
return None
|
|
|
|
try:
|
|
token_count = await asyncio.to_thread(_count_messages_approximately, messages)
|
|
except Exception:
|
|
logger.warning("Failed to count context messages for thread %s", thread_id, exc_info=True)
|
|
return None
|
|
|
|
model_name = await _resolve_thread_model_name(run_store, thread_id, app_config, user_id=user_id)
|
|
model_config = app_config.get_model_config(model_name) if model_name else None
|
|
configured_window = getattr(model_config, "context_window", None) if model_config is not None else None
|
|
max_context_tokens = int(configured_window) if configured_window else None
|
|
return build_context_usage_payload(token_count=token_count, max_context_tokens=max_context_tokens)
|