feat(memhub): add model usage audit and cost controls

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
alvinfeiandCursor committed 2026-10-17 22:02:52 +08:00
1 parent 20b92d8a89
commit 346f2653f2
41 files changed
+1656 -97

No files matched your search

+2
View File
@@ -1123,6 +1123,7 @@ class Extractor:
max_tokens=self._llm_config.agent_max_tokens,
tools=None,
tool_choice=None,
audit_stage="extract",
)
self._call_count += 1
total_tokens += response.tokens_used
@@ -1233,6 +1234,7 @@ class Extractor:
max_tokens=self._llm_config.agent_max_tokens,
tools=None,
tool_choice=None,
audit_stage="extract",
)
self._call_count += 1
total_tokens += response.tokens_used
+21 -16
View File
@@ -34,6 +34,7 @@ import os
import weakref
from ..config import MemoryConfig
from ..utils.model_call_context import model_call_stage
from ..core.workload import llm_slot
logger = logging.getLogger(__name__)
@@ -348,6 +349,7 @@ class LLMProvider:
stop: List[str] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
audit_stage: Optional[str] = None,
**kwargs
) -> LLMResponse:
"""
@@ -386,15 +388,16 @@ class LLMProvider:
len(prompt), _content_fingerprint(prompt),
)
response = await self._call_llm(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
tools=tools,
tool_choice=tool_choice,
**kwargs
)
with model_call_stage(audit_stage):
response = await self._call_llm(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
tools=tools,
tool_choice=tool_choice,
**kwargs
)
self._total_calls += 1
self._total_tokens += response.tokens_used
@@ -595,6 +598,7 @@ class LLMProvider:
temperature: Optional[float] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
audit_stage: Optional[str] = None,
) -> LLMResponse:
"""
多轮 messages 调用(支持 tool results 回传)。
@@ -622,13 +626,14 @@ class LLMProvider:
f"msgs={len(messages)} tools={len(tools) if tools else 0}"
)
response = await self._chat_completions(
messages,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
tool_choice=tool_choice,
)
with model_call_stage(audit_stage):
response = await self._chat_completions(
messages,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
tool_choice=tool_choice,
)
self._total_calls += 1
self._total_tokens += response.tokens_used
+2
View File
@@ -669,6 +669,7 @@ class MemoryReconciler:
response = await llm.complete(
prompt=prompt,
max_tokens=500,
audit_stage="reconcile",
)
text = response.content.strip()
if "```json" in text:
@@ -1383,6 +1384,7 @@ class MemoryReconciler:
response = await llm.complete(
prompt=prompt,
max_tokens=dyn_max_tokens,
audit_stage="reconcile",
)
except Exception as e:
# LLM 彻底不可用(provider 已按 max_retries 重试过,网关 500 /
+1
View File
@@ -919,6 +919,7 @@ class CodingMemoryExtractor:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_extract",
)
except Exception as e:
logger.warning(f"[coding-extract] LLM call failed: {e}")
+2
View File
@@ -571,6 +571,7 @@ class WorkingFactReconciler:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_fact_reconcile",
)
return self._parse_ops(resp.content)
@@ -637,6 +638,7 @@ class WorkingFactReconciler:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=_CHAIN_SUMMARY_MAX_TOKENS,
audit_stage="coding_fact_summary",
)
except Exception as e:
logger.warning(f"[fact-reconcile] chain summary failed: {e}")
+11 -3
View File
@@ -29,6 +29,9 @@ logger = logging.getLogger(__name__)
_JUDGE_MAX_TURNS = 20
_JUDGE_HEAD_TURNS = 5
_JUDGE_USER_CONTENT_LIMIT = 1_000
_JUDGE_USER_CONTENT_HEAD = 200
_JUDGE_USER_CONTENT_TAIL = 200
_JUDGE_MAX_QUERY_CHARS = 300
_JUDGE_MAX_QUERIES = 20
@@ -186,6 +189,7 @@ async def classify_messages_scene(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
audit_stage="coding_judge",
)
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
result = _parse_scene_json(resp.content)
@@ -631,9 +635,13 @@ def _format_turns(summary: List[Dict[str, Any]]) -> str:
rows = []
for t in summary:
user = t.get("user", "") or ""
# 单 turn 用户文本截到 200 字符
if len(user) > 200:
user = user[:200] + "..."
if len(user) > _JUDGE_USER_CONTENT_LIMIT:
omitted = len(user) - _JUDGE_USER_CONTENT_HEAD - _JUDGE_USER_CONTENT_TAIL
user = (
user[:_JUDGE_USER_CONTENT_HEAD]
+ f"\n[...{omitted} chars omitted...]\n"
+ user[-_JUDGE_USER_CONTENT_TAIL:]
)
tools = t.get("tools", []) or []
rows.append(
f"- turn {t.get('turn', '?')}: user={user!r}, tools={tools}"
+1
View File
@@ -310,6 +310,7 @@ class CodingMemoryReconciler:
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_reconcile",
)
return self._parse_ops(resp.content), prompt, resp.content, resp
+24
View File
@@ -0,0 +1,24 @@
"""Context propagated to host-provided HTTP transports for cost accounting."""
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar, Token
_model_call_stage: ContextVar[str] = ContextVar(
"hy_memory_model_call_stage",
default="other",
)
def current_model_call_stage() -> str:
return _model_call_stage.get()
@contextmanager
def model_call_stage(stage: str | None) -> Iterator[None]:
normalized = (stage or "other").strip().lower() or "other"
token: Token[str] = _model_call_stage.set(normalized)
try:
yield
finally:
_model_call_stage.reset(token)
+5 -1
View File
@@ -80,7 +80,11 @@ class PipelineLogWriter:
split_namespace_user,
)
tokens = (prompt_tokens or 0) + (completion_tokens or 0) + (total_tokens or 0)
# Providers define total_tokens as prompt+completion. Adding all
# three fields doubled every cost estimate in this JSONL.
tokens = (total_tokens or 0) or (
(prompt_tokens or 0) + (completion_tokens or 0)
)
namespace, uid = split_namespace_user(user_id or "")
if not is_llm_content_logging_enabled():
prompt = redact_content(prompt)
+3 -3
View File
@@ -3,9 +3,9 @@
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "one-memory",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T11:07:57Z",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T13:52:48Z",
"creationInfo": {
"created": "2026-10-17T11:07:57Z",
"created": "2026-10-17T13:52:48Z",
"creators": [
"Tool: one-memory-generate-sbom"
]
@@ -2335,7 +2335,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.3",
"versionInfo": "2.0.4",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+2 -2
View File
@@ -2,14 +2,14 @@
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
It bundles the runtime dependencies listed below.
Generated from the installed dependency tree on 2026-10-17T11:07:57Z
Generated from the installed dependency tree on 2026-10-17T13:52:48Z
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.3
## hy-memory 2.0.4
License: Apache-2.0
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.3",
"hy-memory==2.0.4",
# Retrieval's derived vector index directly consumes these locked versions.
"chromadb==1.5.9",
"numpy==2.5.2",
+87 -23
View File
@@ -100,7 +100,13 @@ from one_memory.imports.access import (
SourceAccessError,
root_identity_matches,
)
from one_memory.imports.adapters import SourceAdapter, SyncCompleteness
from one_memory.imports.adapters import (
ScanFailure,
ScannedEvidence,
ScanOptions,
SourceAdapter,
SyncCompleteness,
)
from one_memory.imports.candidates import (
SourceCandidateError,
SourceCandidateSelection,
@@ -123,7 +129,7 @@ from one_memory.imports.jobs import (
from one_memory.imports.live_sync import LiveSyncHealth, PollingLiveSyncSupervisor
from one_memory.imports.live_watch import WATCH_MODE, FileWatchLiveSync
from one_memory.imports.locations import SourceLocation, inspect_source_location
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord, WrittenBy
from one_memory.imports.runner import live_sync_arrival
from one_memory.imports.sources.common import SourceInstallState
from one_memory.imports.sources.registry import (
@@ -190,6 +196,7 @@ from one_memory.model_profiles import (
ModelProfileStore,
ModelProfileUnavailableError,
)
from one_memory.model_request_audit import AuditedModelTransport, ModelRequestAuditStore
from one_memory.model_request_budget import BudgetedModelTransport, ModelRequestBudget
from one_memory.onboarding import OnboardingState, OnboardingStore
from one_memory.recall import GovernedRecallTarget, RecallReceipt, RecallRequest
@@ -339,7 +346,7 @@ class ConnectSourcesRequest(BaseModel):
import_upload_id: UUID | None = None
ONBOARDING_LARGE_HISTORY_SESSIONS = 200
ONBOARDING_LARGE_HISTORY_USER_TURNS = 200
ONBOARDING_HISTORY_WINDOW = timedelta(days=3)
@@ -974,6 +981,7 @@ def create_app(
else None
)
app.state.model_request_budget = model_request_budget
app.state.model_request_audit_store = ModelRequestAuditStore(database_path)
app.state.memory_answerer = MemoryAnswerer(
governance=app.state.memory_governance_store,
filter_memories=app.state.source_read_access.memories,
@@ -1016,10 +1024,17 @@ def create_app(
profile=profile,
api_key=api_key,
data_dir=data_dir,
http_transport_factory=(
(lambda: BudgetedModelTransport(model_request_budget))
if model_request_budget is not None
else None
http_transport_factory=lambda: AuditedModelTransport(
app.state.model_request_audit_store,
profile_id=profile.profile_id,
profile_revision=profile.revision,
provider_kind=str(profile.provider_kind),
model=profile.model,
inner=(
BudgetedModelTransport(model_request_budget)
if model_request_budget is not None
else None
),
),
),
# `None` means "whatever the active profile allows". Pinned to
@@ -1749,6 +1764,30 @@ def create_app(
"events": events,
}
@app.get("/api/v1/diagnostics/model-usage", dependencies=protected)
def diagnostic_model_usage(
since: datetime | None = Query(default=None), # noqa: B008
request_id: str | None = Query( # noqa: B008
default=None, min_length=1, max_length=512
),
limit: int = Query(default=200, ge=1, le=2_000), # noqa: B008
) -> dict[str, object]:
"""Return content-free precipitation usage for provider reconciliation."""
normalized_since = (
(since if since.tzinfo is not None else since.replace(tzinfo=UTC))
if since is not None
else None
)
report = app.state.model_request_audit_store.report(
since=normalized_since.astimezone(UTC).isoformat()
if normalized_since is not None
else None,
request_id=request_id,
limit=limit,
)
return {"schema_version": 1, **report}
@app.get(
"/api/v1/runtime/identity",
response_model=HealthResponse,
@@ -4238,7 +4277,7 @@ async def _onboarding_history_after(
home: Path,
now: datetime | None = None,
) -> datetime | None:
"""Return one shared cutoff when any selected harness has large history.
"""Return one shared cutoff when any selected harness has large, old history.
The cutoff is decided before any history job is created, then persisted on
every accepted job. This is deliberately global: independently limiting
@@ -4247,6 +4286,8 @@ async def _onboarding_history_after(
"""
if not request.onboarding:
return None
anchor = now or datetime.now(UTC)
cutoff = anchor - ONBOARDING_HISTORY_WINDOW
revisions = request.source_candidate_revisions or {}
for registration in registrations:
source_id = registration.descriptor.source_id
@@ -4274,29 +4315,52 @@ async def _onboarding_history_after(
home=home,
)
adapter = registration.create_adapter(grant)
count_sessions = cast(
Callable[..., Awaitable[int]] | None,
getattr(adapter, "count_sessions", None),
)
if count_sessions is not None:
session_count = await count_sessions(limit=ONBOARDING_LARGE_HISTORY_SESSIONS + 1)
else:
pending = _install_state(adapter)
if pending is None:
continue
session_count = (await pending).session_count
if await _has_large_old_history(adapter, cutoff=cutoff):
return cutoff
except (OSError, SourceAccessError):
# A supported source whose metadata count cannot be completed is
# treated conservatively. The later connection still owns the
# precise authorization error, while onboarding never falls back
# to an unbounded history import.
session_count = ONBOARDING_LARGE_HISTORY_SESSIONS + 1
if session_count > ONBOARDING_LARGE_HISTORY_SESSIONS:
anchor = now or datetime.now(UTC)
return anchor - ONBOARDING_HISTORY_WINDOW
return cutoff
return None
async def _has_large_old_history(adapter: SourceAdapter, *, cutoff: datetime) -> bool:
"""Probe one authorized Harness with the same parsers the import will use.
Stop as soon as both policy facts are proven: more than 200 genuine person
turns and at least one record older than the three-day cutoff. Counting
parsed Evidence instead of transcript files prevents one giant coding
session from bypassing the limit, while reusing the adapters keeps host
envelopes and tool-shaped user records out of the count.
"""
user_turns = 0
has_old_data = False
async for event in adapter.scan(ScanOptions()):
if isinstance(event, ScanFailure):
# A partial probe must never turn into an unbounded first import.
return True
if not isinstance(event, ScannedEvidence):
continue
observed = event.evidence.created_at
if observed is None and event.cursor.observed_modified_ns > 0:
observed = datetime.fromtimestamp(
event.cursor.observed_modified_ns / 1_000_000_000,
tz=UTC,
)
if observed is not None:
if observed.tzinfo is None:
observed = observed.replace(tzinfo=UTC)
has_old_data = has_old_data or observed.astimezone(UTC) < cutoff
if event.evidence.role == "user" and event.evidence.written_by is WrittenBy.PERSON:
user_turns += 1
if user_turns > ONBOARDING_LARGE_HISTORY_USER_TURNS and has_old_data:
return True
return False
def _install_state(adapter: object) -> Awaitable[SourceInstallState] | None:
"""Ask an adapter for the two-level answer when it has one.
@@ -33,6 +33,7 @@ from one_memory.hy_runtime_config import (
from one_memory.hy_source_context import HySourceContext, HyWriteBindings
from one_memory.hy_store_concurrency import native_store_read_slot
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking
from one_memory.model_request_audit import audit_precipitation_request
from one_memory.semantic_deadline import SemanticMemoryHit
#: D102: how relevant HY has to call a hit before it counts. Raised from 0.25
@@ -143,6 +144,22 @@ class HyMemoryBridgeError(RuntimeError):
pass
MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES = 512
def coding_tool_result_max_bytes() -> int:
"""Return the per-tool payload ceiling used by MemHub's HY client."""
raw = os.environ.get("MEMORY_CODING_TOOL_RESULT_MAX_BYTES")
if raw is None:
return MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
try:
value = int(raw)
except ValueError:
return MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
return value if value > 0 else MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
def _exclusive[**P, T](
method: Callable[P, Coroutine[Any, Any, T]],
) -> Callable[P, Coroutine[Any, Any, T]]:
@@ -414,8 +431,9 @@ class HyMemoryBridge:
@_exclusive
async def ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult:
async with self._write_lock:
return await self._ingest(request)
with audit_precipitation_request(request.request_id):
async with self._write_lock:
return await self._ingest(request)
async def _ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult:
started = monotonic()
@@ -544,8 +562,9 @@ class HyMemoryBridge:
request: HyMemoryIngestRequest,
contents: Sequence[str],
) -> HyMemoryIngestResult:
async with self._write_lock:
return await self._ingest_extracted(request, contents)
with audit_precipitation_request(request.request_id):
async with self._write_lock:
return await self._ingest_extracted(request, contents)
async def _ingest_extracted(
self,
@@ -1697,6 +1716,7 @@ def build_hy_memory_config(
config.history.db_path = str(memory_root / "history.sqlite3")
config.coding.enable = True
config.coding.db_path = str(memory_root / "coding.sqlite3")
config.coding.tool_result_max_bytes = coding_tool_result_max_bytes()
config.recall.episode_recall_enabled = True
config.recall.episode_summary_enabled = False
config.recall.dynamic_episode_enabled = True
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.3"
HY_MEMORY_VERSION = "2.0.4"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -35,7 +35,7 @@ _COLLECTION_LOGGERS = (
# One Memory deliberately stays on ultra because its existing, user-visible
# profile/graph flow calls digest(), which the pinned public SDK exposes only
# for ultra clients. Reader and non-reranker behavior remain pinned for
# compatibility with public SDK 2.0.3.
# compatibility with public SDK 2.0.4.
HY_MEMORY_MODE = "ultra"
# The SDK only recognises `legacy` and `hybrid` (see
# hy_memory/pipelines/_retrieval/config.py: `ALL_READERS = (READER_LEGACY,
@@ -45,6 +45,7 @@ from one_memory.hy_memory_bridge import (
HyMemoryOutput,
HyMemoryTurn,
builtin_retrieval_snapshot,
coding_tool_result_max_bytes,
decide_profile_graph_width,
hy_memory_store_exists,
inspect_profile_graph_width,
@@ -3595,7 +3596,7 @@ _HOST_ENVELOPE = re.compile(
#: before HY chose a path cut long answers mid-way and sent tool-heavy turns as
#: dozens of calls. ONE_MEMORY_SPLIT_TURNS=1 restores the 12,000 / 24 split.
_WHOLE_TURN_CHARACTERS = 60_000
_HY_TOOL_CHARACTERS = 2_048
_HY_TOOL_CHARACTERS = coding_tool_result_max_bytes()
#: Hard ceiling on the UTF-8 bytes one window may hold, whatever HY counts.
#: HY reads the payload in-process, so a turn made of enormous raw tool
#: records is held in memory even though HY will cut each one down. Cut by
@@ -3,7 +3,7 @@
The result decides whether the `unknown` job state can ever be retired, so it
is probed against the installed SDK rather than written down from memory.
What was verified against `hy-memory==2.0.3`:
What was verified against `hy-memory==2.0.4`:
- `HyMemoryClient.add(..., request_id=...)` accepts a caller-supplied id and
echoes it back. It is described as a request/receipt handle, and the SDK
@@ -1231,6 +1231,68 @@ CREATE TABLE IF NOT EXISTS model_request_budget_events (
"""
def model_request_audit_ddl(dialect: str = "mysql") -> str:
"""Content-free accounting for physical precipitation model requests."""
if dialect == "sqlite":
return """
CREATE TABLE IF NOT EXISTS model_request_audit (
audit_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
profile_revision INTEGER NOT NULL,
provider_kind TEXT NOT NULL,
model TEXT NOT NULL,
request_kind TEXT NOT NULL,
prompt_stage TEXT NOT NULL DEFAULT 'other',
started_at TEXT NOT NULL,
completed_at TEXT,
outcome TEXT NOT NULL CHECK(outcome IN ('in_flight','succeeded','failed')),
http_status INTEGER,
error_type TEXT,
provider_request_id TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
cached_prompt_tokens INTEGER,
reasoning_tokens INTEGER
);
CREATE INDEX IF NOT EXISTS model_request_audit_request
ON model_request_audit(request_id, started_at);
CREATE INDEX IF NOT EXISTS model_request_audit_started
ON model_request_audit(started_at);
"""
return """
CREATE TABLE IF NOT EXISTS model_request_audit (
audit_id VARCHAR(128) PRIMARY KEY,
request_id VARCHAR(512) NOT NULL,
profile_id VARCHAR(128) NOT NULL,
profile_revision INT NOT NULL,
provider_kind VARCHAR(64) NOT NULL,
model VARCHAR(512) NOT NULL,
request_kind VARCHAR(64) NOT NULL,
prompt_stage VARCHAR(128) NOT NULL DEFAULT 'other',
started_at DATETIME(6) NOT NULL,
completed_at DATETIME(6) NULL,
outcome VARCHAR(32) NOT NULL CHECK(outcome IN ('in_flight','succeeded','failed')),
http_status INT NULL,
error_type VARCHAR(128) NULL,
provider_request_id VARCHAR(512) NULL,
prompt_tokens BIGINT NULL,
completion_tokens BIGINT NULL,
total_tokens BIGINT NULL,
cached_prompt_tokens BIGINT NULL,
reasoning_tokens BIGINT NULL,
KEY model_request_audit_request (request_id, started_at),
KEY model_request_audit_started (started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
async def _052_model_request_audit(cursor: Any) -> None:
for statement in _split_statements(model_request_audit_ddl(_dialect(cursor))):
await cursor.execute(statement)
def model_probe_operations_ddl(dialect: str = "mysql") -> str:
"""Probe operations; `state` is CHECKed to a known set.
@@ -2849,4 +2911,10 @@ MIGRATIONS: tuple[Migration, ...] = (
_050_model_probe_operations_reserved_at,
),
Migration(51, "drop_hy_node_permissions", _051_drop_hy_node_permissions),
Migration(
52,
"model_request_audit",
_052_model_request_audit,
("model_request_audit",),
),
)
@@ -0,0 +1,404 @@
"""Persistent, content-free accounting for precipitation model requests."""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import UTC, datetime
from importlib import import_module
from pathlib import Path
from typing import TypedDict
from uuid import uuid4
import httpx
from one_memory.database_access import main_database
from one_memory.memory_server.migrations import model_request_audit_ddl
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ModelRequestAuditContext:
request_id: str
_audit_context: ContextVar[ModelRequestAuditContext | None] = ContextVar(
"one_memory_model_request_audit_context",
default=None,
)
@contextmanager
def audit_precipitation_request(request_id: str) -> Iterator[None]:
"""Enable physical-request accounting for one memory precipitation."""
token = _audit_context.set(ModelRequestAuditContext(request_id=request_id))
try:
yield
finally:
_audit_context.reset(token)
def _current_prompt_stage() -> str:
try:
resolver = getattr( # noqa: B009 - optional SDK version boundary
import_module("hy_memory.utils.model_call_context"),
"current_model_call_stage",
)
except (ImportError, AttributeError):
return "other"
return str(resolver())
class ModelRequestAuditStore:
def __init__(self, database: Path) -> None:
self.database = database
with main_database(database).write() as connection, connection:
for statement in model_request_audit_ddl("sqlite").split(";"):
if statement.strip():
connection.execute(statement)
def start(
self,
*,
request_id: str,
profile_id: str,
profile_revision: int,
provider_kind: str,
model: str,
request_kind: str,
prompt_stage: str,
) -> str:
audit_id = str(uuid4())
with main_database(self.database).write() as connection, connection:
connection.execute(
"""
INSERT INTO model_request_audit (
audit_id,request_id,profile_id,profile_revision,provider_kind,
model,request_kind,prompt_stage,started_at,outcome
) VALUES (?,?,?,?,?,?,?,?,?,?)
""",
(
audit_id,
request_id,
profile_id,
profile_revision,
provider_kind,
model,
request_kind,
prompt_stage,
datetime.now(UTC).isoformat(),
"in_flight",
),
)
return audit_id
def finish(
self,
audit_id: str,
*,
outcome: str,
http_status: int | None,
error_type: str | None = None,
provider_request_id: str | None = None,
prompt_tokens: int | None = None,
completion_tokens: int | None = None,
total_tokens: int | None = None,
cached_prompt_tokens: int | None = None,
reasoning_tokens: int | None = None,
) -> None:
with main_database(self.database).write() as connection, connection:
connection.execute(
"""
UPDATE model_request_audit
SET completed_at=?,outcome=?,http_status=?,error_type=?,
provider_request_id=?,prompt_tokens=?,completion_tokens=?,
total_tokens=?,cached_prompt_tokens=?,reasoning_tokens=?
WHERE audit_id=?
""",
(
datetime.now(UTC).isoformat(),
outcome,
http_status,
error_type,
provider_request_id,
prompt_tokens,
completion_tokens,
total_tokens,
cached_prompt_tokens,
reasoning_tokens,
audit_id,
),
)
def report(
self,
*,
since: str | None = None,
request_id: str | None = None,
limit: int = 200,
) -> dict[str, object]:
clauses: list[str] = []
params: list[object] = []
if since is not None:
clauses.append("started_at>=?")
params.append(since)
if request_id is not None:
clauses.append("request_id=?")
params.append(request_id)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with main_database(self.database).read() as connection:
summary = connection.execute(
f"""
SELECT COUNT(*) AS request_count,
SUM(CASE WHEN outcome='succeeded' THEN 1 ELSE 0 END) AS succeeded,
SUM(CASE WHEN outcome='failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN outcome='in_flight' THEN 1 ELSE 0 END) AS in_flight,
SUM(prompt_tokens) AS prompt_tokens,
SUM(completion_tokens) AS completion_tokens,
SUM(total_tokens) AS total_tokens,
SUM(cached_prompt_tokens) AS cached_prompt_tokens,
SUM(reasoning_tokens) AS reasoning_tokens,
AVG(prompt_tokens) AS avg_prompt_tokens,
AVG(completion_tokens) AS avg_completion_tokens
FROM model_request_audit {where}
""",
tuple(params),
).fetchone()
rows = connection.execute(
f"""
SELECT audit_id,request_id,profile_id,profile_revision,provider_kind,
model,request_kind,prompt_stage,started_at,completed_at,outcome,http_status,
error_type,provider_request_id,prompt_tokens,completion_tokens,
total_tokens,cached_prompt_tokens,reasoning_tokens
FROM model_request_audit {where}
ORDER BY started_at DESC,audit_id DESC LIMIT ?
""",
(*params, limit),
).fetchall()
minute_rows = connection.execute(
f"""
SELECT substr(started_at,1,16) || ':00Z' AS minute,
prompt_stage,
COUNT(*) AS request_count,
SUM(prompt_tokens) AS prompt_tokens,
SUM(completion_tokens) AS completion_tokens,
SUM(total_tokens) AS total_tokens,
AVG(prompt_tokens) AS avg_prompt_tokens,
AVG(completion_tokens) AS avg_completion_tokens
FROM model_request_audit {where}
GROUP BY substr(started_at,1,16),prompt_stage
ORDER BY minute,prompt_stage
""",
tuple(params),
).fetchall()
keys = (
"audit_id",
"request_id",
"profile_id",
"profile_revision",
"provider_kind",
"model",
"request_kind",
"prompt_stage",
"started_at",
"completed_at",
"outcome",
"http_status",
"error_type",
"provider_request_id",
"prompt_tokens",
"completion_tokens",
"total_tokens",
"cached_prompt_tokens",
"reasoning_tokens",
)
summary_keys = (
"request_count",
"succeeded",
"failed",
"in_flight",
"prompt_tokens",
"completion_tokens",
"total_tokens",
"cached_prompt_tokens",
"reasoning_tokens",
"avg_prompt_tokens",
"avg_completion_tokens",
)
return {
"summary": {
key: (
round(float(value), 2)
if key.startswith("avg_") and value is not None
else int(value)
if value is not None
else 0
)
for key, value in zip(summary_keys, summary, strict=True)
},
"requests": [
dict(zip(keys, tuple(row), strict=True))
for row in rows
],
"by_minute": [
{
"minute": str(row[0]),
"prompt_stage": str(row[1]),
"request_count": int(row[2]),
"prompt_tokens": int(row[3] or 0),
"completion_tokens": int(row[4] or 0),
"total_tokens": int(row[5] or 0),
"avg_prompt_tokens": round(float(row[6] or 0), 2),
"avg_completion_tokens": round(float(row[7] or 0), 2),
}
for row in minute_rows
],
}
def _non_negative_int(value: object) -> int | None:
return value if type(value) is int and value >= 0 else None
class _ResponseUsage(TypedDict):
provider_request_id: str | None
prompt_tokens: int | None
completion_tokens: int | None
total_tokens: int | None
cached_prompt_tokens: int | None
reasoning_tokens: int | None
def _empty_usage() -> _ResponseUsage:
return {
"provider_request_id": None,
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
"cached_prompt_tokens": None,
"reasoning_tokens": None,
}
def _response_usage(content: bytes) -> _ResponseUsage:
try:
body = json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError):
return _empty_usage()
if not isinstance(body, dict):
return _empty_usage()
usage = body.get("usage")
if not isinstance(usage, dict):
usage = {}
prompt_details = usage.get("prompt_tokens_details")
completion_details = usage.get("completion_tokens_details")
cached = (
prompt_details.get("cached_tokens")
if isinstance(prompt_details, dict)
else usage.get("prompt_cache_hit_tokens")
)
reasoning = (
completion_details.get("reasoning_tokens")
if isinstance(completion_details, dict)
else None
)
return {
"provider_request_id": body.get("id") if isinstance(body.get("id"), str) else None,
"prompt_tokens": _non_negative_int(usage.get("prompt_tokens")),
"completion_tokens": _non_negative_int(usage.get("completion_tokens")),
"total_tokens": _non_negative_int(usage.get("total_tokens")),
"cached_prompt_tokens": _non_negative_int(cached),
"reasoning_tokens": _non_negative_int(reasoning),
}
def _request_kind(request: httpx.Request) -> str:
path = request.url.path.rstrip("/")
if path.endswith("/chat/completions"):
return "chat_completion"
if path.endswith("/embeddings"):
return "embedding"
return "model_http"
def _request_model(content: bytes, fallback: str) -> str:
try:
body = json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError):
return fallback
model = body.get("model") if isinstance(body, dict) else None
return model if isinstance(model, str) and model else fallback
class AuditedModelTransport(httpx.AsyncBaseTransport):
"""Record one row for each physical precipitation HTTP attempt."""
def __init__(
self,
store: ModelRequestAuditStore,
*,
profile_id: str,
profile_revision: int,
provider_kind: str,
model: str,
inner: httpx.AsyncBaseTransport | None = None,
) -> None:
self.store = store
self.profile_id = profile_id
self.profile_revision = profile_revision
self.provider_kind = provider_kind
self.model = model
self.inner = inner or httpx.AsyncHTTPTransport(retries=0, trust_env=False)
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
context = _audit_context.get()
if context is None:
return await self.inner.handle_async_request(request)
content = await request.aread()
audit_id = await asyncio.to_thread(
self.store.start,
request_id=context.request_id,
profile_id=self.profile_id,
profile_revision=self.profile_revision,
provider_kind=self.provider_kind,
model=_request_model(content, self.model),
request_kind=_request_kind(request),
prompt_stage=_current_prompt_stage(),
)
try:
response = await self.inner.handle_async_request(request)
except BaseException as error:
await asyncio.to_thread(
self.store.finish,
audit_id,
outcome="failed",
http_status=None,
error_type=type(error).__name__,
)
raise
content = await response.aread()
usage = _response_usage(content)
try:
await asyncio.to_thread(
self.store.finish,
audit_id,
outcome="succeeded" if response.is_success else "failed",
http_status=response.status_code,
error_type=None if response.is_success else f"http_{response.status_code}",
**usage,
)
except Exception:
# The durable in_flight row is more truthful than hiding the model
# response after it has already incurred cost.
logger.exception("model_request_audit_finish_failed audit_id=%s", audit_id)
return response
async def aclose(self) -> None:
await self.inner.aclose()
+2 -1
View File
@@ -250,6 +250,7 @@
| `test_model_profile_identity.py` | `contract` | Stable model editing and configured-vs-available credentials; no Keychain/network. |
| `test_model_profile_queue_handover.py` | `contract` | Queued work when the model changes or its configuration is deleted (D75). |
| `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 |
| `test_model_request_audit.py` | `contract` | 覆盖 failure is recorded without error message、missing usage stays unknown in detail and zero in summary、records one physical request with provider usage and no content 等 5 个场景。 |
| `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 |
| `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. |
| `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. |
@@ -385,4 +386,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 380 个测试文件。
共 381 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 380
EXPECTED_TEST_FILES_SHA256 = "9f075d18403328882c25325d29ebe77d0e3365d675ba19d56134a6a0fb9b64b4"
EXPECTED_TEST_FILE_COUNT = 381
EXPECTED_TEST_FILES_SHA256 = "1f5100ccee78a9a4ea91805ec07b2054ee4a18911a8162943acedf471ae9b3e7"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -33,6 +33,42 @@ def test_trace_lookup_is_disabled_until_detailed_logging_is_enabled(tmp_path):
assert response.json() == {"schema_version": 1, "state": "disabled", "events": []}
def test_model_usage_route_returns_content_free_totals(tmp_path):
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path,
source_home=tmp_path / "home",
api_token="trace-token",
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
with TestClient(app, headers={"Authorization": "Bearer trace-token"}) as client:
response = client.get("/api/v1/diagnostics/model-usage")
assert response.status_code == 200
assert response.json() == {
"schema_version": 1,
"summary": {
"request_count": 0,
"succeeded": 0,
"failed": 0,
"in_flight": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cached_prompt_tokens": 0,
"reasoning_tokens": 0,
"avg_prompt_tokens": 0,
"avg_completion_tokens": 0,
},
"requests": [],
"by_minute": [],
}
def test_trace_lookup_returns_only_safe_structured_steps(tmp_path, monkeypatch):
monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
set_detailed_logging(tmp_path, True)
@@ -310,6 +310,7 @@ def test_ollama_profile_ignores_ambient_remote_storage_and_models(
monkeypatch.setenv("MEMORY_VECTOR_HOST", "remote.example.com")
monkeypatch.setenv("MEMORY_COLLECTION_NAME", "hostile-collection")
monkeypatch.setenv("MEMORY_EMBEDDING_DIMS", "99")
monkeypatch.delenv("MEMORY_CODING_TOOL_RESULT_MAX_BYTES", raising=False)
profile = snapshot(
ProviderKind.OLLAMA,
base_url="http://127.0.0.1:11434",
@@ -333,6 +334,20 @@ def test_ollama_profile_ignores_ambient_remote_storage_and_models(
assert config.vector_store.embedding_dims == 768
assert config.graph_store.provider == "kuzu"
assert str(tmp_path) in config.graph_store.db_path
assert config.coding.tool_result_max_bytes == 512
def test_memhub_coding_tool_result_ceiling_is_configurable(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MEMORY_CODING_TOOL_RESULT_MAX_BYTES", "1024")
profile = snapshot(
ProviderKind.OLLAMA,
base_url="http://127.0.0.1:11434",
has_api_key=False,
)
config = build_hy_memory_config(profile, api_key=None, data_dir=tmp_path)
assert config.coding.tool_result_max_bytes == 1024
def _kuzu_graph_with_embedding_width(db_path: Path, width: int) -> None:
@@ -1326,7 +1341,7 @@ def test_hy_runtime_matches_public_sdk_without_reranker_or_service_backends(
config = build_hy_memory_config(profile, api_key=None, data_dir=tmp_path)
assert HY_MEMORY_DISTRIBUTION == "hy-memory"
assert HY_MEMORY_VERSION == "2.0.3"
assert HY_MEMORY_VERSION == "2.0.4"
assert HY_MEMORY_MODE == "ultra"
assert HY_MEMORY_READER == "hybrid"
assert os.environ["MEMORY_MODE"] == "ultra"
@@ -69,7 +69,7 @@ def test_no_idempotency_key_means_unknown_must_remain() -> None:
def test_measured_sdk_verdict_is_recorded() -> None:
"""Pin the measured answer for `hy-memory==2.0.3`.
"""Pin the measured answer for `hy-memory==2.0.4`.
The finding: `add()` takes a `request_id` but nothing de-duplicates on it,
while `MemoryInspector.get_pipeline_logs(request_id=...)` can look the
@@ -2103,7 +2103,7 @@ def test_migration_49_resets_only_cursor_history_when_conversation_time_is_missi
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
assert result.applied == (49, 50, 51)
assert result.applied == (49, 50, 51, 52)
with main_database(path).read() as connection:
assert connection.execute("SELECT COUNT(*) FROM source_cursors").fetchone()[0] == 0
assert connection.execute("SELECT COUNT(*) FROM live_source_cursors").fetchone()[0] == 1
@@ -2136,7 +2136,7 @@ def test_upgrade_drops_obsolete_hy_node_permissions_without_touching_memories(
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
assert result.applied == (51,)
assert result.applied == (51, 52)
with sqlite3.connect(path) as connection:
tables = {
row[0]
@@ -0,0 +1,185 @@
import asyncio
import json
import sqlite3
import httpx
import pytest
from one_memory.model_request_audit import (
AuditedModelTransport,
ModelRequestAuditStore,
audit_precipitation_request,
)
def _transport(tmp_path, response):
store = ModelRequestAuditStore(tmp_path / "one-memory.sqlite3")
return store, AuditedModelTransport(
store,
profile_id="profile-1",
profile_revision=2,
provider_kind="openai_compatible",
model="deepseek-flash",
inner=httpx.MockTransport(response),
)
def test_records_one_physical_request_with_provider_usage_and_no_content(
tmp_path, monkeypatch
):
private = "private conversation text"
monkeypatch.setattr(
"one_memory.model_request_audit._current_prompt_stage",
lambda: "coding_judge",
)
def response(_request):
return httpx.Response(
200,
json={
"id": "provider-response-1",
"usage": {
"prompt_tokens": 120,
"completion_tokens": 30,
"total_tokens": 150,
"prompt_tokens_details": {"cached_tokens": 80},
"completion_tokens_details": {"reasoning_tokens": 10},
},
},
)
store, transport = _transport(tmp_path, response)
async def scenario():
async with httpx.AsyncClient(transport=transport) as client:
with audit_precipitation_request("memory-request-1"):
result = await client.post(
"https://example.test/chat/completions",
json={"model": "deepseek-flash", "messages": [{"content": private}]},
)
assert result.status_code == 200
assert result.json()["id"] == "provider-response-1"
asyncio.run(scenario())
report = store.report(request_id="memory-request-1")
assert report["summary"] == {
"request_count": 1,
"succeeded": 1,
"failed": 0,
"in_flight": 0,
"prompt_tokens": 120,
"completion_tokens": 30,
"total_tokens": 150,
"cached_prompt_tokens": 80,
"reasoning_tokens": 10,
"avg_prompt_tokens": 120.0,
"avg_completion_tokens": 30.0,
}
row = report["requests"][0]
assert row["provider_request_id"] == "provider-response-1"
assert row["request_kind"] == "chat_completion"
assert row["prompt_stage"] == "coding_judge"
assert row["profile_revision"] == 2
assert report["by_minute"][0]["prompt_stage"] == "coding_judge"
assert report["by_minute"][0]["total_tokens"] == 150
assert report["by_minute"][0]["avg_prompt_tokens"] == 120.0
assert private.encode() not in store.database.read_bytes()
def test_requests_outside_precipitation_are_not_recorded(tmp_path):
store, transport = _transport(tmp_path, lambda _request: httpx.Response(200, json={}))
async def scenario():
async with httpx.AsyncClient(transport=transport) as client:
await client.post("https://example.test/chat/completions", json={})
asyncio.run(scenario())
assert store.report()["summary"]["request_count"] == 0
def test_precipitation_context_reaches_the_sdk_worker_thread(tmp_path):
store, transport = _transport(
tmp_path,
lambda _request: httpx.Response(
200,
json={"usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3}},
),
)
def worker():
async def request():
async with httpx.AsyncClient(transport=transport) as client:
await client.post("https://example.test/chat/completions", json={})
asyncio.run(request())
async def scenario():
with audit_precipitation_request("threaded-memory-request"):
await asyncio.to_thread(worker)
asyncio.run(scenario())
assert store.report(request_id="threaded-memory-request")["summary"]["total_tokens"] == 3
def test_failure_is_recorded_without_error_message(tmp_path):
secret = "provider leaked private detail"
def response(_request):
raise httpx.ConnectError(secret)
store, transport = _transport(tmp_path, response)
async def scenario():
async with httpx.AsyncClient(transport=transport) as client:
with audit_precipitation_request("memory-request-failed"):
with pytest.raises(httpx.ConnectError):
await client.post("https://example.test/embeddings", json={})
asyncio.run(scenario())
report = store.report(request_id="memory-request-failed")
row = report["requests"][0]
assert row["outcome"] == "failed"
assert row["error_type"] == "ConnectError"
assert row["request_kind"] == "embedding"
assert secret.encode() not in store.database.read_bytes()
def test_missing_usage_stays_unknown_in_detail_and_zero_in_summary(tmp_path):
store, transport = _transport(
tmp_path,
lambda _request: httpx.Response(200, content=json.dumps({"id": "r"}).encode()),
)
async def scenario():
async with httpx.AsyncClient(transport=transport) as client:
with audit_precipitation_request("memory-request-no-usage"):
await client.post("https://example.test/chat/completions", json={})
asyncio.run(scenario())
report = store.report(request_id="memory-request-no-usage")
assert report["requests"][0]["total_tokens"] is None
assert report["summary"]["total_tokens"] == 0
def test_store_schema_matches_migration_and_supports_parallel_starts(tmp_path):
store = ModelRequestAuditStore(tmp_path / "one-memory.sqlite3")
async def start(index):
return await asyncio.to_thread(
store.start,
request_id=f"request-{index}",
profile_id="profile",
profile_revision=1,
provider_kind="openai_compatible",
model="model",
request_kind="chat_completion",
prompt_stage="other",
)
async def scenario():
return await asyncio.gather(*(start(index) for index in range(8)))
ids = asyncio.run(scenario())
assert len(set(ids)) == 8
with sqlite3.connect(store.database) as connection:
assert connection.execute("SELECT COUNT(*) FROM model_request_audit").fetchone() == (8,)
@@ -4,7 +4,7 @@ Dev history: when the model extracted no fact, the coding extractor stored the
raw dialogue as a working fact, and with no words of the person's it took the
first 600 characters of the rendered trace. MemHub showed that as the memory
content, which is where `[0] assistant: ... [1] tool(?): ...` came from
(D87). The SDK fix remains present in 2.0.3: it does not store that fallback by
(D87). The SDK fix remains present in 2.0.4: it does not store that fallback by
default.
This pins the default here, so a future bump that turns it back on, or our own
@@ -195,11 +195,19 @@ def test_connection_retry_reuses_completed_job_and_checks_root_snapshot(tmp_path
asyncio.run(scenario())
@pytest.mark.parametrize(("session_count", "limited"), [(200, False), (201, True)])
@pytest.mark.parametrize(
("turn_count", "age_days", "limited"),
[
(200, 4, False),
(201, 1, False),
(201, 4, True),
],
)
def test_onboarding_applies_large_history_window_to_all_sources(
tmp_path,
monkeypatch,
session_count,
turn_count,
age_days,
limited,
):
monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
@@ -210,8 +218,22 @@ def test_onboarding_applies_large_history_window_to_all_sources(
codex = home / ".codex/sessions"
claude.mkdir(parents=True)
codex.mkdir(parents=True)
for index in range(session_count):
(claude / f"{index:03}.jsonl").write_text("")
timestamp = (datetime.now(UTC) - timedelta(days=age_days)).isoformat()
(claude / "large-session.jsonl").write_text(
"".join(
json.dumps(
{
"type": "user",
"sessionId": "large-session",
"uuid": f"user-{index}",
"timestamp": timestamp,
"message": {"content": f"turn {index}"},
}
)
+ "\n"
for index in range(turn_count)
)
)
(codex / "one.jsonl").write_text("")
app = create_app(
Settings(
@@ -240,7 +262,7 @@ def test_onboarding_applies_large_history_window_to_all_sources(
"/api/v1/sources/connect",
json={
"source_ids": ["claude-code", "codex"],
"request_id": f"history-window-{session_count}",
"request_id": f"history-window-{turn_count}-{age_days}",
"source_roots": {
"claude-code": claude_root,
"codex": codex_root,
@@ -124,7 +124,7 @@ def test_the_sdk_strips_tool_messages_on_the_chat_path():
def test_the_sdk_still_cuts_tool_results_the_way_this_assumes():
"""D74 counts 2 KiB per tool record because the SDK cuts there.
"""MemHub's window estimate follows its configured SDK tool ceiling.
If an SDK upgrade moves that boundary or stops dropping tool records on
the chat path, `_HY_TOOL_CHARACTERS` no longer describes what HY reads and
@@ -141,14 +141,14 @@ def test_the_sdk_still_cuts_tool_results_the_way_this_assumes():
from one_memory.memory_processing import _HY_TOOL_CHARACTERS
assert DEFAULT_MAX_TOOL_RESULT_BYTES == 2048
assert _HY_TOOL_CHARACTERS == 2048
assert _HY_TOOL_CHARACTERS == 512
long = "z" * 20_000
cut = truncate_tool_result_text(long)
assert len(cut) <= DEFAULT_MAX_TOOL_RESULT_BYTES + 128
cut = truncate_tool_result_text(long, max_bytes=_HY_TOOL_CHARACTERS)
assert len(cut.encode()) <= _HY_TOOL_CHARACTERS + 128
from hy_memory.coding.preproc import truncate_messages
messages = [ChatMessage(role="tool", content=long)]
truncated = truncate_messages(messages)
truncated = truncate_messages(messages, max_bytes=_HY_TOOL_CHARACTERS)
assert sum(len(item.content or "") for item in truncated) < len(long)
assert _HY_TOOL_CHARACTERS >= DEFAULT_HEAD_BYTES + DEFAULT_TAIL_BYTES
assert _HY_TOOL_CHARACTERS < DEFAULT_HEAD_BYTES + DEFAULT_TAIL_BYTES
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.3"
version = "2.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chromadb" },
@@ -1008,9 +1008,9 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/12/10e91468d1331eff8380e6b32c1e8784413660a299005ce07ac3e60e90bd/hy_memory-2.0.3.tar.gz", hash = "sha256:cdebd46b16ebe3cb04c89475f9044d30c6cc9065796be369f5d9607410a5c96b", size = 823596, upload-time = "2026-10-17T11:06:23.093Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/d0/44af626b3991a49f787074ebf7474ab68d7adf93a048555ffcdd3092d215/hy_memory-2.0.4.tar.gz", hash = "sha256:ae68837a55afdd50143003608e586350da36c5bd90f4dafcf460c34377179a11", size = 824206, upload-time = "2026-10-17T13:47:29.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/ed/bac8c15eb91baeaeb15a9061d7937ffd02ebe3384757addf0ca09660e592/hy_memory-2.0.3-py3-none-any.whl", hash = "sha256:763d6388ea83762e06c51aecd8e5d8e76211000f67355f65a760eb0a17ede4c5", size = 887157, upload-time = "2026-10-17T11:06:20.994Z" },
{ url = "https://files.pythonhosted.org/packages/0e/68/1c6512a7c3c41cad2731df40416a5331cbc95763feb4983fb59a91869cc7/hy_memory-2.0.4-py3-none-any.whl", hash = "sha256:1d6fd4120185cc6cbcf04f9d81d5bf47e33c467f52787e5d175a412d4e687cd1", size = 888026, upload-time = "2026-10-17T13:47:27.062Z" },
]
[[package]]
@@ -1837,7 +1837,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.3" },
{ name = "hy-memory", specifier = "==2.0.4" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
@@ -19,11 +19,41 @@ import {
const diagnosticLevel = vi.hoisted(() => ({
get: vi.fn(async () => "info" as const),
set: vi.fn(async (level: "error" | "warning" | "info" | "debug") => level),
usage: vi.fn(async () => ({
schema_version: 1 as const,
summary: {
request_count: 3,
succeeded: 3,
failed: 0,
in_flight: 0,
prompt_tokens: 1200,
completion_tokens: 300,
total_tokens: 1500,
cached_prompt_tokens: 0,
reasoning_tokens: 0,
avg_prompt_tokens: 400,
avg_completion_tokens: 100,
},
by_minute: [
{
minute: "2026-10-17T12:47:00Z",
prompt_stage: "coding_judge",
request_count: 1,
prompt_tokens: 200,
completion_tokens: 50,
total_tokens: 250,
avg_prompt_tokens: 200,
avg_completion_tokens: 50,
},
],
requests: [],
})),
}));
vi.mock("./api", () => ({
getDiagnosticLogLevel: diagnosticLevel.get,
setDiagnosticLogLevel: diagnosticLevel.set,
getModelUsage: diagnosticLevel.usage,
}));
const snapshot = {
@@ -60,6 +90,7 @@ beforeEach(() => {
resetRendererDebugLogsForTest();
diagnosticLevel.get.mockClear();
diagnosticLevel.set.mockClear();
diagnosticLevel.usage.mockClear();
window.oneMemory = {
getDebugLogs: vi.fn().mockResolvedValue(snapshot),
} as unknown as NonNullable<typeof window.oneMemory>;
@@ -182,3 +213,25 @@ it("changes the Core and HY capture level independently from display filtering",
.getAttribute("aria-pressed"),
).toBe("true");
});
it("shows per-minute model usage in a separate tab", async () => {
render(<DebugLogPanel />);
await userEvent.click(
screen.getByRole("button", { name: "打开 Debug 日志" }),
);
const panel = await screen.findByRole("complementary", {
name: "Debug 日志",
});
await userEvent.click(within(panel).getByRole("tab", { name: "模型用量" }));
await waitFor(() => expect(diagnosticLevel.usage).toHaveBeenCalled());
expect(within(panel).getByText("Avg Prompt")).toBeTruthy();
expect(within(panel).getByText("400")).toBeTruthy();
expect(within(panel).getAllByText("Judge").length).toBeGreaterThan(0);
expect(
within(panel).getByRole("img", {
name: "按分钟显示各 Prompt 的 Token 花费",
}),
).toBeTruthy();
});
+62 -9
View File
@@ -30,6 +30,7 @@ import {
rendererDebugLogs,
subscribeRendererDebugLogs,
} from "./renderer-debug-log";
import { ModelUsageView } from "./ModelUsageView";
import "./debug-log-panel.css";
const LEVELS: Array<{ value: DebugLogLevel | "all"; label: string }> = [
@@ -52,7 +53,9 @@ const SERVICE_LABELS: Record<DebugLogEntry["service"], string> = {
export function DebugLogPanel() {
const supported = typeof window.oneMemory?.getDebugLogs === "function";
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"logs" | "usage">("logs");
const [live, setLive] = useState(true);
const [usageRefreshToken, setUsageRefreshToken] = useState(0);
const [query, setQuery] = useState("");
const [level, setLevel] = useState<DebugLogLevel | "all">("all");
const [captureLevel, setCaptureLevel] = useState<DiagnosticLogLevel>("info");
@@ -67,6 +70,7 @@ export function DebugLogPanel() {
const snapshotRef = useRef<DebugLogSnapshot | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const listRef = useRef<HTMLDivElement>(null);
const followLatestRef = useRef(true);
const rendererEntries = useSyncExternalStore(
subscribeRendererDebugLogs,
rendererDebugLogs,
@@ -88,7 +92,7 @@ export function DebugLogPanel() {
}, []);
useEffect(() => {
if (!open || !supported) return;
if (!open || !supported || tab !== "logs") return;
void refresh();
void getDiagnosticLogLevel()
.then(setCaptureLevel)
@@ -96,7 +100,7 @@ export function DebugLogPanel() {
if (!live) return;
const timer = window.setInterval(() => void refresh(), 1_000);
return () => window.clearInterval(timer);
}, [live, open, refresh, supported]);
}, [live, open, refresh, supported, tab]);
const entries = useMemo(() => {
const byId = new Map<string, DebugLogEntry>();
@@ -120,9 +124,23 @@ export function DebugLogPanel() {
useEffect(() => {
if (filtered.length === 0) return;
if (!open || !live || !listRef.current) return;
if (
!open ||
tab !== "logs" ||
!live ||
!followLatestRef.current ||
!listRef.current
)
return;
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [filtered.length, live, open]);
}, [filtered.length, live, open, tab]);
const handleListScroll = () => {
const list = listRef.current;
if (!list) return;
followLatestRef.current =
list.scrollHeight - list.scrollTop - list.clientHeight <= 24;
};
const toggleExpanded = (id: string) => {
setExpanded((current) => {
@@ -140,8 +158,12 @@ export function DebugLogPanel() {
<aside className="debug-log-panel" aria-label="Debug 日志">
<header className="debug-log-header">
<div>
<strong>Debug 日志</strong>
<span>{entries.length} 条,本次启动</span>
<strong>诊断</strong>
<span>
{tab === "logs"
? `${entries.length} 条日志,本次启动`
: "记忆沉淀模型用量"}
</span>
</div>
<div className="debug-log-header-actions">
<button
@@ -158,7 +180,10 @@ export function DebugLogPanel() {
className="debug-icon-button"
title="立即刷新"
aria-label="立即刷新"
onClick={() => void refresh()}
onClick={() => {
if (tab === "logs") void refresh();
else setUsageRefreshToken((value) => value + 1);
}}
>
<RefreshCw
size={17}
@@ -176,7 +201,27 @@ export function DebugLogPanel() {
</button>
</div>
</header>
<div className="debug-log-toolbar">
<div className="debug-panel-tabs" role="tablist" aria-label="诊断页面">
<button
type="button"
role="tab"
aria-selected={tab === "logs"}
onClick={() => setTab("logs")}
>
日志
</button>
<button
type="button"
role="tab"
aria-selected={tab === "usage"}
onClick={() => setTab("usage")}
>
模型用量
</button>
</div>
{tab === "logs" ? (
<div className="debug-log-content">
<div className="debug-log-toolbar">
<label className="debug-search">
<Search size={15} aria-hidden="true" />
<input
@@ -245,7 +290,11 @@ export function DebugLogPanel() {
<span className="debug-read-error">读取失败</span>
) : null}
</div>
<div className="debug-log-list" ref={listRef}>
<div
className="debug-log-list"
ref={listRef}
onScroll={handleListScroll}
>
{filtered.length ? (
filtered.map((entry) => {
const isExpanded = expanded.has(entry.id);
@@ -287,6 +336,10 @@ export function DebugLogPanel() {
</p>
)}
</div>
</div>
) : (
<ModelUsageView live={live} refreshToken={usageRefreshToken} />
)}
</aside>
) : null}
<button
+289
View File
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getModelUsage,
type ModelUsageMinute,
type ModelUsageReport,
} from "./api";
const RANGES = [
{ minutes: 15, label: "最近 15 分钟" },
{ minutes: 60, label: "最近 1 小时" },
{ minutes: 360, label: "最近 6 小时" },
{ minutes: 1_440, label: "最近 24 小时" },
{ minutes: 10_080, label: "最近 7 天" },
];
const STAGE_LABELS: Record<string, string> = {
coding_judge: "Judge",
extract: "Extract",
reconcile: "Reconcile",
coding_extract: "Coding Extract",
coding_reconcile: "Coding Reconcile",
coding_fact_reconcile: "Fact Reconcile",
coding_fact_summary: "Fact Summary",
other: "Other",
};
const COLORS = [
"#39759b",
"#b5643b",
"#538665",
"#8a65a3",
"#b08b38",
"#437f82",
"#a04f68",
"#6e7781",
];
interface ModelUsageViewProps {
live: boolean;
refreshToken: number;
}
export function ModelUsageView({
live,
refreshToken,
}: ModelUsageViewProps) {
const [rangeMinutes, setRangeMinutes] = useState(60);
const [report, setReport] = useState<ModelUsageReport | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const load = useCallback(async () => {
setState((current) => (current === "ready" ? current : "loading"));
try {
const since = new Date(Date.now() - rangeMinutes * 60_000).toISOString();
setReport(await getModelUsage(since));
setState("ready");
} catch {
setState("error");
}
}, [rangeMinutes]);
useEffect(() => {
void load();
}, [load, refreshToken]);
useEffect(() => {
if (!live) return;
const timer = window.setInterval(() => void load(), 5_000);
return () => window.clearInterval(timer);
}, [live, load]);
const stages = useMemo(() => summarizeStages(report?.by_minute ?? []), [report]);
return (
<div className="model-usage-view">
<div className="model-usage-toolbar">
<label>
<span>时间范围</span>
<select
aria-label="模型用量时间范围"
value={rangeMinutes}
onChange={(event) => setRangeMinutes(Number(event.target.value))}
>
{RANGES.map((range) => (
<option key={range.minutes} value={range.minutes}>
{range.label}
</option>
))}
</select>
</label>
<span className={`model-usage-state state-${state}`}>
{state === "loading"
? "正在读取…"
: state === "error"
? "读取失败"
: live
? "每 5 秒刷新"
: "已暂停"}
</span>
</div>
<div className="model-usage-metrics">
<Metric label="总 Tokens" value={formatTokens(report?.summary.total_tokens)} />
<Metric
label="Avg Prompt"
value={formatTokens(report?.summary.avg_prompt_tokens)}
/>
<Metric
label="Avg Completion"
value={formatTokens(report?.summary.avg_completion_tokens)}
/>
<Metric label="请求数" value={String(report?.summary.request_count ?? 0)} />
</div>
<section className="model-usage-chart-card" aria-label="各 Prompt 每分钟 Token 曲线">
<div className="model-usage-section-title">
<strong>各 Prompt 花费曲线</strong>
<span>每分钟 · Total Tokens</span>
</div>
<UsageChart rows={report?.by_minute ?? []} />
</section>
<section className="model-usage-stage-card">
<div className="model-usage-section-title">
<strong>Prompt 明细</strong>
<span>当前时间范围</span>
</div>
<div className="model-usage-stage-list">
{stages.length ? (
stages.map((stage) => (
<div className="model-usage-stage-row" key={stage.name}>
<i style={{ background: stageColor(stage.name) }} />
<strong>{stageLabel(stage.name)}</strong>
<span>{stage.requests} 次</span>
<span>Prompt {formatTokens(stage.prompt)}</span>
<span>Completion {formatTokens(stage.completion)}</span>
<b>{formatTokens(stage.total)}</b>
</div>
))
) : (
<p className="debug-log-empty">这个时间范围内没有沉淀模型调用</p>
)}
</div>
</section>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
const minutes = [...new Set(rows.map((row) => row.minute))].sort();
const stages = [...new Set(rows.map((row) => row.prompt_stage))].sort();
const width = 640;
const height = 190;
const pad = { left: 42, right: 12, top: 14, bottom: 28 };
const plotWidth = width - pad.left - pad.right;
const plotHeight = height - pad.top - pad.bottom;
const lookup = new Map(
rows.map((row) => [`${row.minute}\u0000${row.prompt_stage}`, row.total_tokens]),
);
const max = Math.max(1, ...rows.map((row) => row.total_tokens));
const x = (index: number) =>
pad.left + (minutes.length <= 1 ? plotWidth / 2 : (index / (minutes.length - 1)) * plotWidth);
const y = (value: number) => pad.top + plotHeight - (value / max) * plotHeight;
if (!rows.length)
return <div className="model-usage-chart-empty">暂无曲线数据</div>;
return (
<>
<svg
className="model-usage-chart"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label="按分钟显示各 Prompt 的 Token 花费"
>
{[0, 0.5, 1].map((ratio) => (
<g key={ratio}>
<line
x1={pad.left}
x2={width - pad.right}
y1={y(max * ratio)}
y2={y(max * ratio)}
/>
<text x={pad.left - 7} y={y(max * ratio) + 3}>
{formatTokens(max * ratio)}
</text>
</g>
))}
{stages.map((stage) => {
const points = minutes.map((minute, index) => ({
minute,
value: lookup.get(`${minute}\u0000${stage}`) ?? 0,
x: x(index),
}));
return (
<g key={stage}>
<polyline
points={points.map((point) => `${point.x},${y(point.value)}`).join(" ")}
stroke={stageColor(stage)}
/>
{points.map((point) => (
<circle
key={point.minute}
cx={point.x}
cy={y(point.value)}
r="2.4"
fill={stageColor(stage)}
>
<title>{`${stageLabel(stage)} · ${timeLabel(point.minute)} · ${formatTokens(point.value)}`}</title>
</circle>
))}
</g>
);
})}
<text x={pad.left} y={height - 7}>
{timeLabel(minutes[0])}
</text>
<text x={width - pad.right} y={height - 7} textAnchor="end">
{timeLabel(minutes.at(-1) ?? "")}
</text>
</svg>
<div className="model-usage-legend">
{stages.map((stage) => (
<span key={stage}>
<i style={{ background: stageColor(stage) }} />
{stageLabel(stage)}
</span>
))}
</div>
</>
);
}
function summarizeStages(rows: ModelUsageMinute[]) {
const stages = new Map<
string,
{ name: string; requests: number; prompt: number; completion: number; total: number }
>();
for (const row of rows) {
const stage = stages.get(row.prompt_stage) ?? {
name: row.prompt_stage,
requests: 0,
prompt: 0,
completion: 0,
total: 0,
};
stage.requests += row.request_count;
stage.prompt += row.prompt_tokens;
stage.completion += row.completion_tokens;
stage.total += row.total_tokens;
stages.set(row.prompt_stage, stage);
}
return [...stages.values()].sort((left, right) => right.total - left.total);
}
function stageLabel(stage: string): string {
return STAGE_LABELS[stage] ?? stage.replaceAll("_", " ");
}
function stageColor(stage: string): string {
const known = Object.keys(STAGE_LABELS).indexOf(stage);
if (known >= 0) return COLORS[known % COLORS.length];
let hash = 0;
for (const character of stage) hash = (hash * 31 + character.charCodeAt(0)) | 0;
return COLORS[Math.abs(hash) % COLORS.length];
}
function formatTokens(value: number | undefined): string {
const amount = value ?? 0;
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(2)}M`;
if (amount >= 1_000) return `${(amount / 1_000).toFixed(1)}K`;
return Math.round(amount).toLocaleString("zh-CN");
}
function timeLabel(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
+50
View File
@@ -1148,6 +1148,56 @@ export async function setDiagnosticLogLevel(
return value.log_level as DiagnosticLogLevel;
}
export interface ModelUsageSummary {
request_count: number;
succeeded: number;
failed: number;
in_flight: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
cached_prompt_tokens: number;
reasoning_tokens: number;
avg_prompt_tokens: number;
avg_completion_tokens: number;
}
export interface ModelUsageMinute {
minute: string;
prompt_stage: string;
request_count: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
avg_prompt_tokens: number;
avg_completion_tokens: number;
}
export interface ModelUsageReport {
schema_version: 1;
summary: ModelUsageSummary;
by_minute: ModelUsageMinute[];
requests: Array<Record<string, unknown>>;
}
export async function getModelUsage(
since: string,
limit = 200,
): Promise<ModelUsageReport> {
const response = await client.get<ModelUsageReport>(
"/api/v1/diagnostics/model-usage",
{ params: { since, limit } },
);
const value = response.data;
if (
value.schema_version !== 1 ||
!Array.isArray(value.by_minute) ||
typeof value.summary?.request_count !== "number"
)
throw new Error("model_usage_invalid");
return value;
}
export async function retryUnknownMemoryProcessing(
traceId?: string,
): Promise<{ requeued: number }> {
+185 -2
View File
@@ -41,10 +41,10 @@
background: #c93d3d;
}
.debug-log-panel {
width: min(720px, calc(100vw - 112px));
width: min(820px, calc(100vw - 112px));
height: min(760px, calc(100dvh - 92px));
display: grid;
grid-template-rows: auto auto auto auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid rgb(91 110 123 / 30%);
border-radius: 8px;
@@ -93,6 +93,32 @@
.debug-icon-button:hover {
background: rgb(101 123 136 / 12%);
}
.debug-panel-tabs {
display: flex;
gap: 4px;
padding: 7px 12px 0;
border-bottom: 1px solid rgb(106 124 135 / 16%);
}
.debug-panel-tabs button {
height: 32px;
padding: 0 13px;
border: 0;
border-bottom: 2px solid transparent;
color: #687a84;
background: transparent;
font-size: 12px;
cursor: pointer;
}
.debug-panel-tabs button[aria-selected="true"] {
border-bottom-color: #39759b;
color: #213944;
font-weight: 650;
}
.debug-log-content {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
}
.debug-log-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 126px 150px;
@@ -267,6 +293,153 @@
text-align: center;
font-size: 12px;
}
.model-usage-view {
min-height: 0;
overflow: auto;
padding: 12px;
background: #f9fbfc;
}
.model-usage-toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
}
.model-usage-toolbar label {
display: grid;
gap: 4px;
color: #697b85;
font-size: 10px;
}
.model-usage-toolbar select {
height: 34px;
min-width: 150px;
padding: 0 9px;
border: 1px solid rgb(97 117 129 / 28%);
border-radius: 6px;
color: #273842;
background: white;
font-size: 12px;
}
.model-usage-state {
color: #71828b;
font-size: 11px;
}
.model-usage-state.state-error {
color: #b83232;
}
.model-usage-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.model-usage-metrics > div {
display: grid;
gap: 5px;
padding: 11px 12px;
border: 1px solid rgb(100 119 131 / 18%);
border-radius: 7px;
background: white;
}
.model-usage-metrics span {
color: #71828b;
font-size: 10px;
}
.model-usage-metrics strong {
color: #20343f;
font-size: 19px;
font-variant-numeric: tabular-nums;
}
.model-usage-chart-card,
.model-usage-stage-card {
margin-top: 10px;
border: 1px solid rgb(100 119 131 / 18%);
border-radius: 7px;
background: white;
}
.model-usage-section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 12px 4px;
}
.model-usage-section-title strong {
font-size: 12px;
}
.model-usage-section-title span {
color: #71828b;
font-size: 10px;
}
.model-usage-chart {
width: 100%;
height: 210px;
overflow: visible;
}
.model-usage-chart line {
stroke: rgb(108 125 136 / 18%);
stroke-width: 1;
}
.model-usage-chart polyline {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.model-usage-chart text {
fill: #7a8991;
font-size: 9px;
font-variant-numeric: tabular-nums;
}
.model-usage-chart-empty {
display: grid;
height: 210px;
place-items: center;
color: #71828b;
font-size: 12px;
}
.model-usage-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
padding: 0 12px 11px;
color: #5d707b;
font-size: 10px;
}
.model-usage-legend span,
.model-usage-stage-row {
display: flex;
align-items: center;
}
.model-usage-legend i,
.model-usage-stage-row i {
width: 7px;
height: 7px;
flex: 0 0 auto;
margin-right: 5px;
border-radius: 50%;
}
.model-usage-stage-list {
padding: 4px 12px 10px;
}
.model-usage-stage-row {
min-height: 34px;
display: grid;
grid-template-columns: 12px minmax(110px, 1fr) 54px 110px 128px 70px;
gap: 7px;
border-top: 1px solid rgb(110 127 137 / 12%);
color: #687984;
font-size: 10px;
}
.model-usage-stage-row strong {
color: #314650;
font-size: 11px;
}
.model-usage-stage-row b {
color: #263c47;
text-align: right;
font-variant-numeric: tabular-nums;
}
.spin {
animation: debug-spin 0.9s linear infinite;
}
@@ -296,6 +469,16 @@
.debug-log-outcome {
display: none;
}
.model-usage-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.model-usage-stage-row {
grid-template-columns: 12px minmax(100px, 1fr) 50px 80px;
}
.model-usage-stage-row span:nth-of-type(2),
.model-usage-stage-row span:nth-of-type(3) {
display: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.debug-fab,
+1 -1
View File
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
HY_MEMORY_NAME = "hy-memory"
HY_MEMORY_VERSION = "2.0.3"
HY_MEMORY_VERSION = "2.0.4"
class SbomError(RuntimeError):
+3 -3
View File
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
dist_info = tmp_path / "hy_memory-2.0.3.dist-info"
dist_info = tmp_path / "hy_memory-2.0.4.dist-info"
licenses = dist_info / "licenses"
licenses.mkdir(parents=True)
(dist_info / "METADATA").write_text(
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
(
"Metadata-Version: 2.4",
"Name: hy-memory",
"Version: 2.0.3",
"Version: 2.0.4",
"License-Expression: Apache-2.0",
"",
)
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
notice = module.hy_memory_notice(dist_info)
assert notice["name"] == "hy-memory"
assert notice["version"] == "2.0.3"
assert notice["version"] == "2.0.4"
assert notice["license"] == "Apache-2.0"
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
assert "Licensed under the Apache License" in notice["text"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hy-memory"
version = "2.0.3"
version = "2.0.4"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"
+25
View File
@@ -119,6 +119,31 @@ class TestClassifyMessages:
assert "Read" in last_prompt
assert "重构 auth" in last_prompt or "user=" in last_prompt
async def test_long_user_content_keeps_only_head_and_tail(self):
head = "H" * 200
middle = "M" * 800
tail = "T" * 200
llm = MockLLMProvider('{"scene":"coding"}')
await classify_messages_scene(
[ChatMessage(role="user", content=head + middle + tail)],
llm,
)
prompt = llm.calls[-1]["prompt"]
assert head in prompt
assert tail in prompt
assert "M" * 201 not in prompt
assert "[...800 chars omitted...]" in prompt
async def test_user_content_at_limit_is_not_truncated(self):
content = "A" * 1_000
llm = MockLLMProvider('{"scene":"chat"}')
await classify_messages_scene([ChatMessage(role="user", content=content)], llm)
assert content in llm.calls[-1]["prompt"]
@pytest.mark.asyncio
class TestClassifyQueries:
+24
View File
@@ -7,6 +7,7 @@ import openai
from hy_memory.agent.llm_provider import LLMProvider
from hy_memory.config import MemoryConfig
from hy_memory.core.embed_service import EmbedService
from hy_memory.utils.model_call_context import current_model_call_stage
class _Transport(httpx.AsyncBaseTransport):
@@ -82,3 +83,26 @@ def test_llm_provider_uses_runtime_transport_factory(monkeypatch) -> None:
assert len(transports) == 1
assert isinstance(captured["http_client"], httpx.AsyncClient)
asyncio.run(provider.close())
def test_llm_provider_exposes_audit_stage_during_physical_call(monkeypatch) -> None:
config = MemoryConfig()
provider = LLMProvider(config)
observed = []
async def call(**_kwargs):
observed.append(current_model_call_stage())
return SimpleNamespace(
content="ok",
tokens_used=3,
prompt_tokens=2,
completion_tokens=1,
tool_calls=[],
)
monkeypatch.setattr(provider, "_call_llm", call)
asyncio.run(provider.complete("hello", audit_stage="coding_judge"))
assert observed == ["coding_judge"]
assert current_model_call_stage() == "other"
+18 -1
View File
@@ -2,7 +2,6 @@
import asyncio
import json
import os
from pathlib import Path
import pytest
@@ -293,6 +292,24 @@ class TestPipelineLogSubdirSplit:
assert (tmp_path / "app1").is_dir()
def test_pipeline_jsonl_does_not_add_total_tokens_twice(tmp_path):
from datetime import date
from hy_memory.utils.pipeline_log_writer import PipelineLogWriter
writer = PipelineLogWriter(str(tmp_path), "default")
writer.write_step(
step="EXTRACT",
prompt_tokens=120,
completion_tokens=30,
total_tokens=150,
)
log_file = tmp_path / "default" / f"{date.today().isoformat()}.log"
row = json.loads(log_file.read_text(encoding="utf-8").strip())
assert row["tokens"] == 150
class TestPipelineLogContentPrivacy:
"""Full LLM content is opt-in; default logs retain only length/hash metadata."""