feat(memhub): add model usage audit and cost controls
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
20b92d8a89
commit
346f2653f2
41 files changed
+1656
-97
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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,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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Generated
+4
-4
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user