Files

491 lines
16 KiB
Python

"""Keep one day of the exact text sent to a model and the text it sent back.
The admin page reads this store. Rows older than a day are dropped. Bodies are
capped so a long tool argument cannot grow the file without limit. Recording
never raises into the model call.
"""
from __future__ import annotations
import contextvars
import json
import logging
import os
import tempfile
import threading
import uuid
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_RETENTION = timedelta(days=1)
_STORE_NAME = "model-exchanges.jsonl"
_MAX_RECORDS = 100
_MAX_FIELD_CHARS = 200_000
_PREVIEW_CHARS = 80
_TRUNCATED = "\n…(超出保存长度,后面已截断)"
_LOCK = threading.Lock()
_ACTIVE: contextvars.ContextVar[ExchangeCapture | None] = contextvars.ContextVar(
"deerflow_model_exchange",
default=None,
)
_store_override: Path | None = None
@dataclass
class ExchangeCapture:
"""One in-flight model call. Text stays here until the call finishes."""
model: str = ""
enable_thinking: str = "unset"
request_text: str = ""
request_body: str = ""
request_preview: str = ""
content_parts: list[str] = field(default_factory=list)
reasoning_by_field: dict[str, list[str]] = field(default_factory=dict)
reasoning_fields: list[str] = field(default_factory=list)
raw_chunks: list[str] = field(default_factory=list)
owned: bool = False
token: contextvars.Token | None = None
saved: bool = False
def use_exchange_store(path: Path | None) -> None:
"""Point the store at a test file. ``None`` restores the runtime data dir."""
global _store_override
_store_override = path
def open_exchange() -> ExchangeCapture:
return ExchangeCapture()
def bind_exchange(capture: ExchangeCapture) -> contextvars.Token:
return _ACTIVE.set(capture)
def unbind_exchange(token: contextvars.Token) -> None:
_ACTIVE.reset(token)
def activate_exchange() -> tuple[ExchangeCapture, contextvars.Token]:
capture = open_exchange()
return capture, bind_exchange(capture)
def deactivate_exchange(capture: ExchangeCapture, token: contextvars.Token) -> None:
try:
finish_exchange(capture)
finally:
unbind_exchange(token)
def remember_request(payload: Mapping[str, Any]) -> None:
"""Copy the outbound messages once, after the payload is final."""
try:
capture = _ACTIVE.get()
if capture is None:
capture = ExchangeCapture(owned=True)
capture.token = _ACTIVE.set(capture)
elif capture.owned and capture.request_text and not capture.saved:
_discard_owned(capture)
capture = ExchangeCapture(owned=True)
capture.token = _ACTIVE.set(capture)
if capture.request_text:
return
messages = payload.get("messages")
capture.model = str(payload.get("model") or "")
capture.enable_thinking = _thinking_flag(payload)
capture.request_preview = _preview(_last_user_text(messages))
capture.request_text = _request_text(payload, messages)
capture.request_body = _replay_request(payload)
except Exception:
logger.debug("model exchange request was not captured", exc_info=True)
def remember_provider_chunk(chunk: Mapping[str, Any]) -> None:
"""Append one provider chunk exactly as it arrived."""
try:
capture = _ACTIVE.get()
if capture is None:
return
_store_raw(capture, chunk)
for choice in _choices(chunk):
delta = choice.get("delta") if isinstance(choice.get("delta"), Mapping) else {}
message = choice.get("message") if isinstance(choice.get("message"), Mapping) else {}
_append_channel(capture.content_parts, _text_field(delta, "content") or _text_field(message, "content"))
_take_reasoning(capture, delta)
_take_reasoning(capture, message)
except Exception:
logger.debug("model exchange chunk was not captured", exc_info=True)
def remember_raw_response(payload: Mapping[str, Any]) -> None:
"""Keep a non-streaming response body exactly as the provider sent it."""
try:
capture = _ACTIVE.get()
if capture is None:
return
_store_raw(capture, payload, only_if_empty=True)
except Exception:
logger.debug("model exchange response was not captured", exc_info=True)
def remember_final_message(message: Mapping[str, Any]) -> None:
"""Record a non-streaming message when no chunks were captured."""
try:
capture = _ACTIVE.get()
if capture is None:
return
_store_raw(capture, message, only_if_empty=True)
if capture.content_parts or _has_reasoning(capture):
return
_append_channel(capture.content_parts, _text_field(message, "content"))
_take_reasoning(capture, message)
except Exception:
logger.debug("model exchange message was not captured", exc_info=True)
def finish_owned_exchange() -> None:
capture = _ACTIVE.get()
if capture is None or not capture.owned:
return
token = capture.token
try:
finish_exchange(capture)
finally:
if token is not None:
_ACTIVE.reset(token)
def finish_exchange(capture: ExchangeCapture) -> None:
if capture.saved or not capture.request_text:
capture.saved = True
return
capture.saved = True
content = "".join(capture.content_parts)
reasoning_by_field = {
name: "".join(capture.reasoning_by_field.get(name, []))
for name in capture.reasoning_fields
if "".join(capture.reasoning_by_field.get(name, []))
}
reasoning = "".join(reasoning_by_field.values())
reasoning_field = "、".join(reasoning_by_field) or "(没有单独的思考字段)"
response_raw = _cap("\n\n".join(capture.raw_chunks))
preview_source = content or reasoning
record = {
"id": uuid.uuid4().hex,
"at": datetime.now(UTC).isoformat(timespec="seconds"),
"model": capture.model,
"user_id": _current_user_id(),
"enable_thinking": capture.enable_thinking,
"request_preview": capture.request_preview,
"response_preview": _preview(preview_source),
"request_text": _cap(capture.request_text),
"request_body": capture.request_body,
"response_text": response_raw,
"response_raw": response_raw,
"response_content": _cap(content),
"response_reasoning": _cap(reasoning),
"reasoning_field": reasoning_field,
}
_append_record(record)
def query_model_exchanges() -> list[dict[str, Any]]:
"""Newest first. Entries older than one day are dropped."""
with _LOCK:
records = _read_records()
_write_records(records)
return list(reversed(records))
def _discard_owned(capture: ExchangeCapture) -> None:
capture.saved = True
token = capture.token
if token is not None:
_ACTIVE.reset(token)
def _store_path() -> Path:
if _store_override is not None:
return _store_override
from deerflow.config.paths import get_paths
return get_paths().base_dir / _STORE_NAME
def _append_record(record: dict[str, Any]) -> None:
try:
with _LOCK:
records = _read_records()
records.append(record)
if len(records) > _MAX_RECORDS:
records = records[-_MAX_RECORDS:]
_write_records(records)
except Exception:
logger.debug("model exchange was not saved", exc_info=True)
def _read_records() -> list[dict[str, Any]]:
path = _store_path()
if not path.is_file():
return []
cutoff = datetime.now(UTC) - _RETENTION
kept: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(record, dict) and _record_time(record) >= cutoff:
kept.append(record)
if len(kept) > _MAX_RECORDS:
kept = kept[-_MAX_RECORDS:]
return kept
def _write_records(records: list[dict[str, Any]]) -> None:
path = _store_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".model-exchanges.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _record_time(record: Mapping[str, Any]) -> datetime:
raw = str(record.get("at") or "")
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return datetime.min.replace(tzinfo=UTC)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _current_user_id() -> str:
try:
from deerflow.runtime.user_context import get_effective_user_id
return get_effective_user_id()
except Exception:
return ""
def _thinking_flag(payload: Mapping[str, Any]) -> str:
extra_body = payload.get("extra_body")
value = extra_body.get("enable_thinking", payload.get("enable_thinking")) if isinstance(extra_body, Mapping) else payload.get("enable_thinking")
if value is True:
return "true"
if value is False:
return "false"
if value is None:
return "unset"
return "other"
def _choices(chunk: Mapping[str, Any]) -> list[Mapping[str, Any]]:
choices = chunk.get("choices")
if not isinstance(choices, list) or not choices:
nested = chunk.get("chunk")
if isinstance(nested, Mapping):
choices = nested.get("choices")
if not isinstance(choices, list):
return []
return [choice for choice in choices if isinstance(choice, Mapping)]
def _has_reasoning(capture: ExchangeCapture) -> bool:
return any(parts for parts in capture.reasoning_by_field.values())
def _take_reasoning(capture: ExchangeCapture, source: Mapping[str, Any]) -> None:
"""Keep thinking text under the wire field that actually carried it."""
for name in ("reasoning_content", "reasoning"):
text = _text_field(source, name)
if not text:
continue
if name not in capture.reasoning_fields:
capture.reasoning_fields.append(name)
_append_channel(capture.reasoning_by_field.setdefault(name, []), text)
def _text_field(payload: Mapping[str, Any], key: str) -> str:
value = payload.get(key)
if isinstance(value, str):
return value
return _content_to_text(value) if key == "content" else ""
def _content_to_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, Mapping):
text = item.get("text")
if isinstance(text, str) and text:
parts.append(text)
elif item.get("type") == "image_url":
parts.append("[image]")
return "\n".join(parts)
if content is None:
return ""
return str(content)
def _last_user_text(messages: Any) -> str:
if not isinstance(messages, list):
return ""
for message in reversed(messages):
if isinstance(message, Mapping) and message.get("role") == "user":
return _content_to_text(message.get("content"))
return ""
_SECRET_KEYS = frozenset({"api_key", "openai_api_key", "authorization", "token"})
def _replay_request(payload: Mapping[str, Any]) -> str:
"""JSON body the OpenAI client actually posts. ``extra_body`` is flattened."""
ready = _json_ready(payload)
if not isinstance(ready, dict):
return "{}"
extra = ready.pop("extra_body", None)
if isinstance(extra, dict):
ready.update(extra)
return _fit_replay(ready)
def _store_raw(capture: ExchangeCapture, payload: Mapping[str, Any], *, only_if_empty: bool = False) -> None:
if only_if_empty and capture.raw_chunks:
return
if sum(len(part) for part in capture.raw_chunks) >= _MAX_FIELD_CHARS:
return
capture.raw_chunks.append(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
def _json_ready(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Mapping):
return {
str(key): _json_ready(item)
for key, item in value.items()
if item is not None and str(key) not in _SECRET_KEYS
}
if isinstance(value, (list, tuple)):
return [_json_ready(item) for item in value]
return str(value)
def _fit_replay(body: dict[str, Any]) -> str:
encoded = json.dumps(body, ensure_ascii=False, indent=2)
if len(encoded) <= _MAX_FIELD_CHARS:
return encoded
tools = body.get("tools")
if isinstance(tools, list) and tools:
names: list[str] = []
for tool in tools:
function = tool.get("function") if isinstance(tool, dict) else None
name = function.get("name") if isinstance(function, dict) else None
if isinstance(name, str) and name and name not in names:
names.append(name)
body["tools"] = [{"_omitted": "工具定义过长,这里只保留工具名", "names": names}]
encoded = json.dumps(body, ensure_ascii=False, indent=2)
if len(encoded) <= _MAX_FIELD_CHARS:
return encoded
messages = body.get("messages")
if isinstance(messages, list):
for _ in range(12):
if len(encoded) <= _MAX_FIELD_CHARS:
break
longest: dict[str, Any] | None = None
longest_len = 0
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if isinstance(content, str) and len(content) > longest_len:
longest = message
longest_len = len(content)
if longest is None or longest_len < 200:
break
longest["content"] = longest["content"][: max(80, longest_len // 2)] + _TRUNCATED.strip()
encoded = json.dumps(body, ensure_ascii=False, indent=2)
if len(encoded) > _MAX_FIELD_CHARS:
body["messages"] = [{"role": "user", "content": _TRUNCATED.strip()}]
encoded = json.dumps(body, ensure_ascii=False, indent=2)
return encoded
def _request_text(payload: Mapping[str, Any], messages: Any) -> str:
tool_names: list[str] = []
tools = payload.get("tools")
if isinstance(tools, list):
for tool in tools:
function = tool.get("function") if isinstance(tool, Mapping) else None
name = function.get("name") if isinstance(function, Mapping) else None
if isinstance(name, str) and name and name not in tool_names:
tool_names.append(name)
lines = [
f"enable_thinking: {_thinking_flag(payload)}",
f"tools: {', '.join(tool_names) if tool_names else '(无)'}",
"",
]
if isinstance(messages, list):
for message in messages:
if not isinstance(message, Mapping):
continue
role = str(message.get("role") or "unknown")
lines.append(f"[{role}]")
lines.append(_content_to_text(message.get("content")))
lines.append("")
return "\n".join(lines).rstrip()
def _append_channel(parts: list[str], incoming: str) -> None:
if not incoming:
return
current = "".join(parts)
if current and (incoming == current or incoming.startswith(current)):
parts.clear()
parts.append(incoming)
return
if current and current.endswith(incoming):
return
parts.append(incoming)
def _preview(text: str) -> str:
compact = " ".join(text.split())
if len(compact) <= _PREVIEW_CHARS:
return compact
return compact[: _PREVIEW_CHARS - 1] + "…"
def _cap(text: str) -> str:
if len(text) <= _MAX_FIELD_CHARS:
return text
return text[: _MAX_FIELD_CHARS - len(_TRUNCATED)] + _TRUNCATED