Files
AIRegulation-DocAnalysis/docs/superpowers/plans/2026-07-02-status-llm-model-usage.md
T

1756 lines
74 KiB
Markdown
Raw Normal View History

# System Status — AI Model Connections & Token Usage Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a new "AI Models" panel to the existing System Status page showing which LLM/Embedding/Reranker models are configured, their connection status, and cumulative token usage since process start.
**Architecture:** Instrument the single shared LLM client factory (`get_llm_client()`) with a transparent `TrackedLLMClient` decorator so every call site (main answer generation, HyDE, Agentic RAG steps, compliance review, document summarization, regulation-perception analysis) is captured automatically. Instrument the embedding provider and reranker directly (each has only one implementation). All data is held in-memory by a new `ModelUsageTracker` singleton (`app/shared/model_usage_tracker.py`); two new endpoints on the existing `/status` router expose it; a new card on `StatusPage.tsx` displays it.
**Tech Stack:** FastAPI, Python 3.10+ dataclasses, `asyncio.to_thread`/`asyncio.gather` (existing convention), React 19 + TypeScript, existing `fetchAPI` helper.
**Design reference:** `docs/superpowers/specs/2026-07-02-status-llm-model-usage-design.md` — read this first for the "why" behind each decision below.
## Global Constraints
- Every new/modified Python file under `backend/` must have an English module docstring, docstrings on every class/function, and at least one meaningful `#` comment (project-wide rule; see `AGENTS.md`).
- No new business orchestration goes in `backend/app/services/*` or `backend/app/workflows/*` — new code goes in `backend/app/shared/` (cross-cutting support, same tier as `bootstrap.py`) or directly instruments the single existing infrastructure implementation.
- In-memory only — no new database table, no new dependency. Token counts reset on backend restart; this is an explicit, approved product decision, not a shortcut to fix later.
- Tracking code must never raise into a real request path — every public tracker method swallows its own exceptions and logs a warning.
- Frontend: desktop-first, no responsive/mobile work (`AGENTS.md`). No new frontend test framework — this repo has none (`frontend/package.json` has no test script); verify with `npm --prefix frontend run lint` and `npm --prefix frontend run build` only.
- Backend tests run via `pytest` (project already depends on `pytest>=7.0.0`, `pytest-asyncio>=0.21.0`). Root `pyproject.toml` sets `testpaths = ["tests"]`, so bare `pytest` only auto-discovers the root `tests/` directory — tests under `backend/tests/` must be invoked with an explicit path. Use `uv run pytest <path> -v` (per `AGENTS.md`, this resolves within the project's `.venv`; if `uv`/`.venv` are not yet set up, run `./dev.sh setup` or `dev.bat setup` first, per `AGENTS.md`).
- `LLMFactory._global_instances` and `get_model_usage_tracker()` are both process-wide singletons/caches. Every test that touches them must clear their state in a fixture to avoid cross-test pollution — this is called out explicitly in the tasks below.
---
### Task 1: `ModelUsageTracker` — in-memory usage/connection registry
**Files:**
- Create: `backend/app/shared/model_usage_tracker.py`
- Create: `backend/tests/observability/__init__.py`
- Test: `backend/tests/observability/test_model_usage_tracker.py`
**Interfaces:**
- Produces: `ModelUsageEntry` (dataclass: `provider: str`, `model: str`, `total_tokens: int`, `prompt_tokens: int`, `completion_tokens: int`, `call_count_ok: int`, `call_count_error: int`, `last_called_at: datetime | None`, `last_latency_ms: int | None`, `last_error: str | None`, property `status -> str` returning `"never_called" | "ok" | "error"`); `ModelUsageTracker` (methods `record(*, provider, model, success, usage=None, latency_ms=None, error=None) -> None`, `snapshot() -> dict[str, ModelUsageEntry]`, `get(provider, model) -> ModelUsageEntry | None`); `get_model_usage_tracker() -> ModelUsageTracker` (process-wide singleton via `@lru_cache`).
- [ ] **Step 1: Write the failing test**
Create `backend/tests/observability/__init__.py` (empty file, matches the `__init__.py`-per-test-package convention already used by `backend/tests/perception/` and `backend/tests/compliance/`).
Create `backend/tests/observability/test_model_usage_tracker.py`:
```python
"""Unit tests for ModelUsageTracker — no mocking needed, pure in-memory state."""
from __future__ import annotations
from app.shared.model_usage_tracker import ModelUsageEntry, ModelUsageTracker, get_model_usage_tracker
def test_never_called_model_has_no_entry():
"""A tracker that has never recorded a call returns None from get()."""
tracker = ModelUsageTracker()
assert tracker.get("deepseek", "deepseek-v4-flash") is None
def test_record_success_accumulates_tokens_and_calls():
"""Two successful calls accumulate tokens and call_count_ok."""
tracker = ModelUsageTracker()
tracker.record(
provider="deepseek", model="deepseek-v4-flash", success=True,
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, latency_ms=100,
)
tracker.record(
provider="deepseek", model="deepseek-v4-flash", success=True,
usage={"prompt_tokens": 20, "completion_tokens": 8, "total_tokens": 28}, latency_ms=200,
)
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry is not None
assert entry.total_tokens == 43
assert entry.prompt_tokens == 30
assert entry.completion_tokens == 13
assert entry.call_count_ok == 2
assert entry.call_count_error == 0
assert entry.status == "ok"
assert entry.last_latency_ms == 200
def test_record_error_sets_error_status_without_losing_prior_tokens():
"""A failed call after successful ones flips status to 'error' but keeps accumulated tokens."""
tracker = ModelUsageTracker()
tracker.record(provider="qwen", model="qwen3.5-flash", success=True, usage={"total_tokens": 50}, latency_ms=50)
tracker.record(provider="qwen", model="qwen3.5-flash", success=False, error="HTTP 500", latency_ms=30)
entry = tracker.get("qwen", "qwen3.5-flash")
assert entry.total_tokens == 50
assert entry.call_count_ok == 1
assert entry.call_count_error == 1
assert entry.status == "error"
assert entry.last_error == "HTTP 500"
def test_record_success_after_error_clears_last_error():
"""A later successful call clears last_error and status returns to 'ok'."""
tracker = ModelUsageTracker()
tracker.record(provider="qwen", model="qwen3.5-flash", success=False, error="timeout", latency_ms=30)
tracker.record(provider="qwen", model="qwen3.5-flash", success=True, usage={"total_tokens": 5}, latency_ms=40)
entry = tracker.get("qwen", "qwen3.5-flash")
assert entry.status == "ok"
assert entry.last_error is None
def test_record_never_raises_on_bad_usage_dict():
"""A malformed usage value (wrong type) is swallowed, not raised, and does not corrupt other entries."""
tracker = ModelUsageTracker()
tracker.record(provider="embedding", model="text-embedding-v3", success=True, usage="not-a-dict", latency_ms=10) # type: ignore[arg-type]
# Must not raise, and must not have created a corrupted entry that breaks snapshot().
snapshot = tracker.snapshot()
assert isinstance(snapshot, dict)
def test_snapshot_returns_independent_copy():
"""snapshot() returns a dict that can be safely mutated without affecting the tracker."""
tracker = ModelUsageTracker()
tracker.record(provider="deepseek", model="deepseek-v4-flash", success=True, usage={"total_tokens": 1}, latency_ms=1)
snap = tracker.snapshot()
snap.clear()
assert tracker.get("deepseek", "deepseek-v4-flash") is not None
def test_get_model_usage_tracker_returns_singleton():
"""get_model_usage_tracker() always returns the same process-wide instance."""
assert get_model_usage_tracker() is get_model_usage_tracker()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest backend/tests/observability/test_model_usage_tracker.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.shared.model_usage_tracker'`
- [ ] **Step 3: Write minimal implementation**
Create `backend/app/shared/model_usage_tracker.py`:
```python
"""In-memory registry that tracks per-model call outcomes and token usage.
This module lives in `app/shared` — the same cross-cutting-support tier as
`bootstrap.py` — because it is not business logic: it exists purely so the
System Status page can show which AI models (main LLM, HyDE LLM, embedding,
reranker) are configured, whether their most recent call succeeded, and how
many tokens they have consumed since this process started. Tracking here
must never disrupt a real user-facing call: every public method swallows its
own exceptions and logs a warning instead of raising.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from loguru import logger
@dataclass
class ModelUsageEntry:
"""Represent accumulated usage/connection state for one provider+model pair."""
provider: str
model: str
total_tokens: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
call_count_ok: int = 0
call_count_error: int = 0
last_called_at: datetime | None = None
last_latency_ms: int | None = None
last_error: str | None = None
@property
def status(self) -> str:
"""Derive never_called/ok/error from call history.
The "disabled" status (reranker only, when turned off in settings) is
NOT decided here: this dataclass has no access to live settings. The
API route layer (Task 6) applies that override on top of this value,
so config always wins over stale historical data.
"""
if self.last_called_at is None:
return "never_called"
return "error" if self.last_error else "ok"
class ModelUsageTracker:
"""Thread-safe in-memory registry of per-model call/usage stats.
Keyed by "{provider}:{model}" rather than by business role (main LLM /
HyDE / embedding / reranker) so that any future call site is captured
automatically, even before anyone teaches this class about its role.
"""
def __init__(self) -> None:
"""Initialize an empty registry guarded by a single lock."""
self._entries: dict[str, ModelUsageEntry] = {}
# One coarse lock is enough: record() runs at most a few times per
# request, and snapshot() is only read by the low-traffic status page.
self._lock = threading.Lock()
def record(
self,
*,
provider: str,
model: str,
success: bool,
usage: dict | None = None,
latency_ms: int | None = None,
error: str | None = None,
) -> None:
"""Record the outcome of one call to provider/model.
Never raises: any internal failure is logged and swallowed so a bug
in observability code cannot break a real LLM/embedding/reranker call.
"""
try:
key = f"{provider}:{model}"
usage = usage if isinstance(usage, dict) else {}
with self._lock:
entry = self._entries.setdefault(key, ModelUsageEntry(provider=provider, model=model))
entry.total_tokens += int(usage.get("total_tokens", 0) or 0)
entry.prompt_tokens += int(usage.get("prompt_tokens", 0) or 0)
entry.completion_tokens += int(usage.get("completion_tokens", 0) or 0)
if success:
entry.call_count_ok += 1
entry.last_error = None
else:
entry.call_count_error += 1
entry.last_error = error or "unknown error"
entry.last_called_at = datetime.now(timezone.utc)
entry.last_latency_ms = latency_ms
except Exception as exc: # noqa: BLE001 - tracking must never break a real call
logger.warning("ModelUsageTracker.record failed for {}:{} - {}", provider, model, exc)
def snapshot(self) -> dict[str, ModelUsageEntry]:
"""Return a shallow copy of all tracked entries, safe to mutate by the caller."""
with self._lock:
return dict(self._entries)
def get(self, provider: str, model: str) -> ModelUsageEntry | None:
"""Return the entry for one provider/model pair, or None if never recorded."""
return self.snapshot().get(f"{provider}:{model}")
@lru_cache
def get_model_usage_tracker() -> ModelUsageTracker:
"""Return the process-wide singleton tracker (mirrors get_settings()/get_llm_factory())."""
return ModelUsageTracker()
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest backend/tests/observability/test_model_usage_tracker.py -v`
Expected: PASS (7 tests)
- [ ] **Step 5: Commit**
```bash
git add backend/app/shared/model_usage_tracker.py backend/tests/observability/
git commit -m "feat: add ModelUsageTracker for per-model token/connection tracking"
```
---
### Task 2: `TrackedLLMClient` — transparent usage-recording decorator
**Files:**
- Create: `backend/app/services/llm/tracked_client.py`
- Create: `backend/tests/observability/test_tracked_client.py`
**Interfaces:**
- Consumes: `ModelUsageTracker` (Task 1, `backend/app/shared/model_usage_tracker.py`), `BaseLLMClient`/`LLMResponse` (`backend/app/services/llm/base_client.py`, existing).
- Produces: `TrackedLLMClient(inner: BaseLLMClient, tracker: ModelUsageTracker)` with `.chat(...)`, `.stream_chat(...)`, and `__getattr__` passthrough for everything else (`get_available_models`, `close`, `.config`).
- [ ] **Step 1: Write the failing test**
Create `backend/tests/observability/test_tracked_client.py`:
```python
"""Unit tests for TrackedLLMClient — verifies transparent delegation + recording."""
from __future__ import annotations
from unittest.mock import MagicMock
from app.services.llm.base_client import LLMConfig, LLMProvider, LLMResponse
from app.services.llm.tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import ModelUsageTracker
def _make_inner(model: str = "deepseek-v4-flash") -> MagicMock:
"""Build a MagicMock standing in for a concrete BaseLLMClient subclass."""
inner = MagicMock()
inner.config = LLMConfig(
provider=LLMProvider.DEEPSEEK, model=model, api_key="test-key", base_url="http://example.test/v1",
)
return inner
def test_chat_delegates_and_returns_unchanged_response():
"""chat() must return exactly what the wrapped client returned."""
inner = _make_inner()
expected = LLMResponse(content="hello", model="deepseek-v4-flash", usage={"total_tokens": 12})
inner.chat.return_value = expected
tracker = ModelUsageTracker()
tracked = TrackedLLMClient(inner, tracker)
result = tracked.chat([{"role": "user", "content": "hi"}])
assert result is expected
inner.chat.assert_called_once_with([{"role": "user", "content": "hi"}], None, None, None)
def test_chat_records_success_and_tokens():
"""A successful chat() call must be recorded under 'deepseek:deepseek-v4-flash'."""
inner = _make_inner()
inner.chat.return_value = LLMResponse(content="hi", model="deepseek-v4-flash", usage={"total_tokens": 42})
tracker = ModelUsageTracker()
TrackedLLMClient(inner, tracker).chat([{"role": "user", "content": "hi"}])
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry is not None
assert entry.total_tokens == 42
assert entry.status == "ok"
def test_chat_records_error_from_response():
"""A chat() call that returns an error-carrying LLMResponse is recorded as a failure."""
inner = _make_inner()
inner.chat.return_value = LLMResponse(content="", model="deepseek-v4-flash", error="API error: 500")
tracker = ModelUsageTracker()
TrackedLLMClient(inner, tracker).chat([{"role": "user", "content": "hi"}])
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry.status == "error"
assert entry.last_error == "API error: 500"
def test_getattr_forwards_to_inner_client():
"""Attributes not defined on TrackedLLMClient must forward to the wrapped client."""
inner = _make_inner()
inner.get_available_models.return_value = ["deepseek-v4-flash"]
tracked = TrackedLLMClient(inner, ModelUsageTracker())
assert tracked.get_available_models() == ["deepseek-v4-flash"]
assert tracked.config is inner.config
def test_stream_chat_records_call_without_token_usage():
"""stream_chat() must record a call (latency/success) but not fabricate token counts."""
inner = _make_inner()
inner.stream_chat.return_value = iter(["chunk-1", "chunk-2"])
tracker = ModelUsageTracker()
chunks = list(TrackedLLMClient(inner, tracker).stream_chat([{"role": "user", "content": "hi"}]))
assert chunks == ["chunk-1", "chunk-2"]
entry = tracker.get("deepseek", "deepseek-v4-flash")
assert entry.call_count_ok == 1
assert entry.total_tokens == 0
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest backend/tests/observability/test_tracked_client.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.services.llm.tracked_client'`
- [ ] **Step 3: Write minimal implementation**
Create `backend/app/services/llm/tracked_client.py`:
```python
"""Transparent decorator around BaseLLMClient implementations.
Records per-call token usage, latency, and success/failure into a
ModelUsageTracker without changing any caller-visible behavior.
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
from app.shared.model_usage_tracker import ModelUsageTracker
from .base_client import BaseLLMClient, LLMResponse
from .tool_types import Tool
class TrackedLLMClient:
"""Wrap any BaseLLMClient and record its usage into a ModelUsageTracker.
Deliberately does NOT subclass BaseLLMClient: that ABC declares abstract
methods (_init_client, get_available_models) with no meaningful override
here, and subclassing would make Python refuse to instantiate this class
("Can't instantiate abstract class") before __getattr__ ever got a chance
to forward the call. Plain composition + __getattr__ delegation works
because every caller in this codebase only ever uses duck-typed access:
.chat(), .stream_chat(), .get_available_models(), .close(), .config.
"""
def __init__(self, inner: BaseLLMClient, tracker: ModelUsageTracker) -> None:
"""Store the wrapped client and the tracker to report into."""
self._inner = inner
self._tracker = tracker
def chat(
self,
messages: List[Dict[str, str]],
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
tools: Optional[List[Tool]] = None,
**kwargs: Any,
) -> LLMResponse:
"""Delegate to the wrapped client's chat(), then record the outcome."""
start = time.time()
response = self._inner.chat(messages, max_tokens, temperature, tools, **kwargs)
# Key by the *configured* model, not response.model, so lookups driven
# by settings (llm_model / hyde_llm_model) always match what we recorded.
self._tracker.record(
provider=self._inner.config.provider.value,
model=self._inner.config.model,
success=response.is_success,
usage=response.usage,
latency_ms=int((time.time() - start) * 1000),
error=response.error,
)
return response
def stream_chat(self, messages: List[Dict[str, str]], *args: Any, **kwargs: Any):
"""Delegate to the wrapped client's stream_chat(), recording call outcome only.
Token usage is NOT recorded here: none of the current provider
stream_chat() implementations parse a trailing usage chunk from the
gateway (see the design doc's Known Limitations), so accumulating a
token count here would silently be wrong. Only call success/failure
and latency are tracked for streaming calls.
"""
start = time.time()
error: Optional[str] = None
try:
for chunk in self._inner.stream_chat(messages, *args, **kwargs):
yield chunk
except Exception as exc: # noqa: BLE001 - report, then re-raise unchanged
error = str(exc)
raise
finally:
self._tracker.record(
provider=self._inner.config.provider.value,
model=self._inner.config.model,
success=error is None,
latency_ms=int((time.time() - start) * 1000),
error=error,
)
def __getattr__(self, name: str) -> Any:
"""Forward any other attribute/method access to the wrapped client."""
return getattr(self._inner, name)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest backend/tests/observability/test_tracked_client.py -v`
Expected: PASS (5 tests)
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/llm/tracked_client.py backend/tests/observability/test_tracked_client.py
git commit -m "feat: add TrackedLLMClient decorator for transparent usage recording"
```
---
### Task 3: Wire `TrackedLLMClient` into `LLMFactory`
**Files:**
- Modify: `backend/app/services/llm/llm_factory.py:1-30` (imports), `:39-83` (`create()`), `:140-145` (`get_cached()`), `:199-212` (`get_llm_client()`)
- Test: `backend/tests/observability/test_llm_factory_tracking.py`
**Interfaces:**
- Consumes: `TrackedLLMClient` (Task 2), `get_model_usage_tracker()` (Task 1).
- Produces: `get_llm_client(...)` now returns a `TrackedLLMClient`-wrapped instance (return type becomes `BaseLLMClient | TrackedLLMClient`) — no signature change for any caller.
- [ ] **Step 1: Write the failing test**
Create `backend/tests/observability/test_llm_factory_tracking.py`:
```python
"""Verifies get_llm_client() returns a usage-tracked client end to end."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.services.llm.llm_factory import LLMFactory, get_llm_client
from app.services.llm.tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_singletons():
"""Clear the two process-wide singletons this test touches, before and after.
LLMFactory._global_instances and get_model_usage_tracker() both persist
for the life of the process; without this fixture, tests would leak
cached clients/usage data into each other and become order-dependent.
"""
LLMFactory._global_instances.clear()
get_model_usage_tracker().snapshot() # no-op read, just documents intent
get_model_usage_tracker()._entries.clear()
yield
LLMFactory._global_instances.clear()
get_model_usage_tracker()._entries.clear()
def test_get_llm_client_returns_tracked_client():
"""get_llm_client() must return a TrackedLLMClient, not the raw provider client."""
with patch("app.services.llm.llm_factory.DeepSeekClient") as mock_cls:
mock_cls.return_value = MagicMock()
client = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
assert isinstance(client, TrackedLLMClient)
def test_get_llm_client_caches_the_tracked_instance():
"""A second call with the same provider/model must return the same TrackedLLMClient."""
with patch("app.services.llm.llm_factory.DeepSeekClient") as mock_cls:
mock_cls.return_value = MagicMock()
first = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
second = get_llm_client(provider="deepseek", model="deepseek-v4-flash", api_key="test-key")
assert first is second
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest backend/tests/observability/test_llm_factory_tracking.py -v`
Expected: FAIL — `assert isinstance(client, TrackedLLMClient)` fails because `create()` does not wrap yet.
- [ ] **Step 3: Write minimal implementation**
In `backend/app/services/llm/llm_factory.py`, add two imports near the top (after the existing `from .qwen_client import QwenClient, QwenVLClient` line):
```python
from .tracked_client import TrackedLLMClient
from app.shared.model_usage_tracker import get_model_usage_tracker
```
Modify `create()` — insert the wrapping step between building the client and caching it:
```python
# Keep provider-specific behavior explicit so debugging stays straightforward.
client = self._create_client(config)
# Wrap in TrackedLLMClient so every call site (agentic, HyDE, perception,
# compliance, document summarization, main answer generation) is recorded
# without each of them needing to know about usage tracking.
tracked_client = TrackedLLMClient(client, get_model_usage_tracker())
# Keep provider-specific behavior explicit so debugging stays straightforward.
LLMFactory._global_instances[cache_key] = tracked_client
logger.info(f"LLM客户端创建成功并缓存: {provider} - {model}")
return tracked_client
```
Update the `create()` method's return type annotation from `-> BaseLLMClient` to `-> "BaseLLMClient | TrackedLLMClient"`.
Update `get_cached()`'s return type annotation from `Optional[BaseLLMClient]` to `"BaseLLMClient | TrackedLLMClient | None"`.
Update the module-level `get_llm_client()` function's return type annotation from `-> BaseLLMClient` to `-> "BaseLLMClient | TrackedLLMClient"`.
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest backend/tests/observability/test_llm_factory_tracking.py -v`
Expected: PASS (2 tests)
Then re-run Tasks 1-2's tests together to confirm no regressions:
Run: `uv run pytest backend/tests/observability/ -v`
Expected: PASS (all tests from Tasks 1-3)
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/llm/llm_factory.py backend/tests/observability/test_llm_factory_tracking.py
git commit -m "feat: wrap LLM clients with TrackedLLMClient in LLMFactory"
```
---
### Task 4: Instrument the embedding provider
**Files:**
- Modify: `backend/app/infrastructure/embedding/openai_compatible_embedding_provider.py` (full file, 79 lines)
- Test: `backend/tests/observability/test_embedding_usage_tracking.py`
**Interfaces:**
- Consumes: `get_model_usage_tracker()` (Task 1).
- Produces: no interface change — `OpenAICompatibleEmbeddingProvider.embed_texts`/`embed_query` behave identically; only a side effect (tracking) is added. Tracker key is `"embedding:{settings.embedding_model}"`.
- [ ] **Step 1: Write the failing test**
Create `backend/tests/observability/test_embedding_usage_tracking.py`:
```python
"""Verifies OpenAICompatibleEmbeddingProvider records usage into ModelUsageTracker."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
from app.infrastructure.embedding.openai_compatible_embedding_provider import OpenAICompatibleEmbeddingProvider
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_tracker():
"""Clear the process-wide tracker before and after each test in this file."""
get_model_usage_tracker()._entries.clear()
yield
get_model_usage_tracker()._entries.clear()
def _fake_response(usage: dict) -> MagicMock:
"""Build a fake httpx.Response-like object for a successful embeddings call."""
resp = MagicMock(spec=httpx.Response)
resp.raise_for_status.return_value = None
resp.json.return_value = {
"data": [{"index": 0, "embedding": [0.1] * 1024}],
"usage": usage,
}
return resp
def test_successful_embed_records_usage():
"""A successful embeddings call must record token usage under 'embedding:<model>'."""
provider = OpenAICompatibleEmbeddingProvider()
provider.api_key = "test-key"
with patch("httpx.post", return_value=_fake_response({"prompt_tokens": 3, "total_tokens": 3})):
provider.embed_query("hello")
entry = get_model_usage_tracker().get("embedding", provider.model)
assert entry is not None
assert entry.total_tokens == 3
assert entry.status == "ok"
def test_failed_embed_records_error():
"""An HTTP error from the embeddings endpoint must be recorded as a failure, then re-raised."""
provider = OpenAICompatibleEmbeddingProvider()
provider.api_key = "test-key"
failing_response = MagicMock(spec=httpx.Response)
failing_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"boom", request=MagicMock(), response=MagicMock(status_code=500, text="boom")
)
failing_response.text = "boom"
with patch("httpx.post", return_value=failing_response):
with pytest.raises(httpx.HTTPStatusError):
provider.embed_query("hello")
entry = get_model_usage_tracker().get("embedding", provider.model)
assert entry is not None
assert entry.status == "error"
assert entry.call_count_error == 1
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest backend/tests/observability/test_embedding_usage_tracking.py -v`
Expected: FAIL — `entry` is `None` (no tracking wired yet).
- [ ] **Step 3: Write minimal implementation**
Replace the full contents of `backend/app/infrastructure/embedding/openai_compatible_embedding_provider.py`:
```python
"""Implement infrastructure support for openai compatible embedding provider."""
from __future__ import annotations
import os
import time
import httpx
from app.config.settings import settings
from app.domain.retrieval import EmbeddingProvider
from app.shared.model_usage_tracker import get_model_usage_tracker
# Keep adapter behavior explicit so integration details remain easy to audit.
EMBEDDING_BATCH_SIZE = 8
class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
"""Provide the Open A I Compatible Embedding Provider provider."""
def __init__(self) -> None:
"""Initialize the Open A I Compatible Embedding Provider instance."""
self.base_url = settings.embedding_base_url.rstrip("/")
self.api_key = (
settings.embedding_api_key
or os.getenv("OPENAI_API_KEY", "")
or os.getenv("QWEN_API_KEY", "")
or os.getenv("DEEPSEEK_API_KEY", "")
)
self.model = settings.embedding_model
self.timeout = settings.embedding_timeout_seconds
self.dimension = settings.embedding_dim
def _raise_for_status(self, response: httpx.Response, *, batch_size: int) -> None:
"""Raise a detailed error so upstream gateway failures are easier to diagnose."""
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
response_preview = response.text[:500].strip()
detail = (
f"Embedding request failed for model={self.model}, batch_size={batch_size}, "
f"status={response.status_code}, url={response.request.url}, response={response_preview}"
)
raise httpx.HTTPStatusError(detail, request=exc.request, response=exc.response) from exc
def _request(self, texts: list[str]) -> list[list[float]]:
"""Handle request for this module for the Open A I Compatible Embedding Provider instance."""
if not self.api_key:
raise ValueError("缺少 EMBEDDING_API_KEY / OPENAI_API_KEY")
start = time.time()
try:
response = httpx.post(
f"{self.base_url}/embeddings",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={"model": self.model, "input": texts},
timeout=self.timeout,
)
self._raise_for_status(response, batch_size=len(texts))
data = response.json()
except Exception as exc:
# Record the failed call so the Status page can show it as an error,
# then re-raise unchanged so existing callers keep their current behavior.
get_model_usage_tracker().record(
provider="embedding",
model=self.model,
success=False,
latency_ms=int((time.time() - start) * 1000),
error=str(exc),
)
raise
vectors = [item["embedding"] for item in sorted(data.get("data", []), key=lambda item: item["index"])]
if any(len(vector) != self.dimension for vector in vectors):
raise ValueError(f"embedding 维度不匹配,期望 {self.dimension}")
# Record token usage from the OpenAI-compatible response, e.g. {"total_tokens": N}.
get_model_usage_tracker().record(
provider="embedding",
model=self.model,
success=True,
usage=data.get("usage", {}),
latency_ms=int((time.time() - start) * 1000),
)
return vectors
def embed_texts(self, texts: list[str]) -> list[list[float]]:
"""Embed texts for the Open A I Compatible Embedding Provider instance."""
if not texts:
return []
vectors: list[list[float]] = []
# Batch requests conservatively because some gateways reject larger embedding payloads.
for start in range(0, len(texts), EMBEDDING_BATCH_SIZE):
batch = texts[start:start + EMBEDDING_BATCH_SIZE]
vectors.extend(self._request(batch))
return vectors
def embed_query(self, text: str) -> list[float]:
"""Embed query for the Open A I Compatible Embedding Provider instance."""
vectors = self._request([text])
return vectors[0]
```
(Only the `_request` method changed: added `import time`, `from app.shared.model_usage_tracker import get_model_usage_tracker`, wrapped the request in try/except to record failures, and record success with `usage` after the dimension check.)
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest backend/tests/observability/test_embedding_usage_tracking.py -v`
Expected: PASS (2 tests)
- [ ] **Step 5: Commit**
```bash
git add backend/app/infrastructure/embedding/openai_compatible_embedding_provider.py backend/tests/observability/test_embedding_usage_tracking.py
git commit -m "feat: record embedding call usage into ModelUsageTracker"
```
---
### Task 5: Instrument the reranker
**Files:**
- Modify: `backend/app/infrastructure/vectorstore/cross_encoder_reranker.py:1-54` (imports + `rerank()`)
- Test: `backend/tests/observability/test_reranker_usage_tracking.py`
**Interfaces:**
- Consumes: `get_model_usage_tracker()` (Task 1).
- Produces: no interface change — `OpenAICompatibleReranker.rerank()` behaves identically; tracker key is `"reranker:{settings.reranker_model}"`. No token usage is ever recorded for this role (TEI/Cohere-style rerank APIs do not return one) — only call success/failure and latency.
- [ ] **Step 1: Write the failing test**
Create `backend/tests/observability/test_reranker_usage_tracking.py`:
```python
"""Verifies OpenAICompatibleReranker records call outcome (no tokens) into ModelUsageTracker."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from app.domain.retrieval import RetrievedChunk
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_tracker():
"""Clear the process-wide tracker before and after each test in this file."""
get_model_usage_tracker()._entries.clear()
yield
get_model_usage_tracker()._entries.clear()
def _chunk(chunk_id: str, text: str) -> RetrievedChunk:
"""Build a minimal RetrievedChunk for reranker tests."""
return RetrievedChunk(chunk_id=chunk_id, doc_id="doc-1", doc_title="Doc", text=text, score=0.0)
def test_successful_rerank_records_call_without_tokens():
"""A successful rerank() call is recorded with call_count_ok but zero tokens."""
reranker = OpenAICompatibleReranker(base_url="http://example.test", model="bge-reranker-v2-m3")
with patch.object(reranker, "_call_reranker", return_value=[0.9, 0.1]):
result = reranker.rerank("query", [_chunk("c1", "a"), _chunk("c2", "b")], top_k=2)
assert len(result) == 2
entry = get_model_usage_tracker().get("reranker", "bge-reranker-v2-m3")
assert entry is not None
assert entry.call_count_ok == 1
assert entry.total_tokens == 0
def test_failed_rerank_records_error_and_falls_back():
"""A rerank() call that raises internally is recorded as an error but still returns a fallback list."""
reranker = OpenAICompatibleReranker(base_url="http://example.test", model="bge-reranker-v2-m3")
with patch.object(reranker, "_call_reranker", side_effect=RuntimeError("gateway down")):
result = reranker.rerank("query", [_chunk("c1", "a")], top_k=1)
assert len(result) == 1 # existing fallback behavior: original order, unscored
entry = get_model_usage_tracker().get("reranker", "bge-reranker-v2-m3")
assert entry is not None
assert entry.call_count_error == 1
assert entry.status == "error"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest backend/tests/observability/test_reranker_usage_tracking.py -v`
Expected: FAIL — `entry` is `None` (no tracking wired yet).
- [ ] **Step 3: Write minimal implementation**
In `backend/app/infrastructure/vectorstore/cross_encoder_reranker.py`, add an import after the existing `from app.domain.retrieval import Reranker, RetrievedChunk` line:
```python
from app.shared.model_usage_tracker import get_model_usage_tracker
```
Replace the `rerank()` method body with:
```python
def rerank(self, query: str, chunks: list[RetrievedChunk], top_k: int) -> list[RetrievedChunk]:
"""Return up to top_k chunks re-sorted by cross-encoder score."""
if not chunks:
return []
texts = [chunk.text for chunk in chunks]
start = time.time()
try:
scores = self._call_reranker(query, texts)
except Exception as exc:
logger.warning("Reranker call failed ({}), falling back to original order: {}", type(exc).__name__, exc)
# Record the failure so the Status page reflects real reranker health.
get_model_usage_tracker().record(
provider="reranker",
model=self._model,
success=False,
latency_ms=int((time.time() - start) * 1000),
error=str(exc),
)
return chunks[:top_k]
elapsed_ms = int((time.time() - start) * 1000)
logger.debug("Reranker scored {} chunks in {}ms", len(chunks), elapsed_ms)
# TEI/Cohere-style rerank responses carry no token usage field —
# only call success/latency is meaningful for this role.
get_model_usage_tracker().record(
provider="reranker",
model=self._model,
success=True,
latency_ms=elapsed_ms,
)
ranked = sorted(
[(score, chunk) for score, chunk in zip(scores, chunks)],
key=lambda x: x[0],
reverse=True,
)
result = []
for score, chunk in ranked[:top_k]:
chunk.score = float(score)
result.append(chunk)
return result
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest backend/tests/observability/test_reranker_usage_tracking.py -v`
Expected: PASS (2 tests)
Then run the full new test package together:
Run: `uv run pytest backend/tests/observability/ -v`
Expected: PASS (all tests from Tasks 1-5)
- [ ] **Step 5: Commit**
```bash
git add backend/app/infrastructure/vectorstore/cross_encoder_reranker.py backend/tests/observability/test_reranker_usage_tracking.py
git commit -m "feat: record reranker call outcome into ModelUsageTracker"
```
---
### Task 6: `GET /status/models` and `POST /status/models/ping` routes
**Files:**
- Modify: `backend/app/api/routes/status.py` (full file, 114 lines)
- Test: `tests/test_status_models_routes.py` (root `tests/`, matching `tests/test_auth_routes.py` convention for route-level tests)
**Interfaces:**
- Consumes: `get_model_usage_tracker()` (Task 1), `get_llm_client()` (Task 3, now tracked), `get_embedding_provider()`/`get_reranker()` (existing bootstrap accessors, Task 4/5 now instrument what they return), `RetrievedChunk` (`app.domain.retrieval`, existing).
- Produces: `GET /api/v1/status/models``{"models": [<4 entries>]}`; `POST /api/v1/status/models/ping` → same shape, refreshed. Each entry: `{role, role_label, provider, model, enabled, status, total_tokens, call_count_ok, call_count_error, last_called_at, last_latency_ms, last_error, shares_usage_with}`.
- [ ] **Step 1: Write the failing test**
Create `tests/test_status_models_routes.py`:
```python
"""Integration tests for the /status/models routes.
Uses FastAPI TestClient with mocked LLM/embedding/reranker clients so no
external gateway or database is required.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.services.llm.base_client import LLMResponse
from app.shared.model_usage_tracker import get_model_usage_tracker
@pytest.fixture(autouse=True)
def _reset_tracker():
"""Clear the process-wide tracker before and after each test in this file."""
get_model_usage_tracker()._entries.clear()
yield
get_model_usage_tracker()._entries.clear()
@pytest.fixture
def client():
"""Return a TestClient for the real app (status routes require no auth)."""
from app.api.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
def test_get_models_returns_four_roles_never_called_by_default(client):
"""With no calls made yet, all 4 roles are returned with status 'never_called' or 'disabled'."""
resp = client.get("/api/v1/status/models")
assert resp.status_code == 200
body = resp.json()
roles = {m["role"] for m in body["models"]}
assert roles == {"main_llm", "hyde_llm", "embedding", "reranker"}
reranker_row = next(m for m in body["models"] if m["role"] == "reranker")
# Default .env.example ships RERANKER_ENABLED=false.
from app.config.settings import settings
assert reranker_row["enabled"] == settings.reranker_enabled
if not settings.reranker_enabled:
assert reranker_row["status"] == "disabled"
def test_get_models_reflects_recorded_usage(client):
"""A previously recorded call must show up in total_tokens/status."""
from app.config.settings import settings
get_model_usage_tracker().record(
provider=settings.llm_provider, model=settings.llm_model, success=True, usage={"total_tokens": 99},
)
resp = client.get("/api/v1/status/models")
main_row = next(m for m in resp.json()["models"] if m["role"] == "main_llm")
assert main_row["total_tokens"] == 99
assert main_row["status"] == "ok"
def test_ping_models_calls_each_enabled_model_once(client):
"""POST /status/models/ping must invoke chat()/embed_query() and return fresh statuses."""
mock_llm_response = LLMResponse(content="pong", model="test-model", usage={"total_tokens": 1})
mock_llm_client = MagicMock()
mock_llm_client.chat.return_value = mock_llm_response
mock_embedding = MagicMock()
mock_embedding.embed_query.return_value = [0.1]
with patch("app.api.routes.status.get_llm_client", return_value=mock_llm_client), \
patch("app.api.routes.status.get_embedding_provider", return_value=mock_embedding), \
patch("app.api.routes.status.get_reranker", return_value=None):
resp = client.post("/api/v1/status/models/ping")
assert resp.status_code == 200
body = resp.json()
assert len(body["models"]) == 4
assert mock_llm_client.chat.call_count >= 1
mock_embedding.embed_query.assert_called_once()
def test_ping_models_survives_one_model_failing(client):
"""If the LLM ping raises, embedding/reranker pings must still be attempted and a 200 returned."""
mock_embedding = MagicMock()
mock_embedding.embed_query.return_value = [0.1]
with patch("app.api.routes.status.get_llm_client", side_effect=RuntimeError("gateway down")), \
patch("app.api.routes.status.get_embedding_provider", return_value=mock_embedding), \
patch("app.api.routes.status.get_reranker", return_value=None):
resp = client.post("/api/v1/status/models/ping")
assert resp.status_code == 200
mock_embedding.embed_query.assert_called_once()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_status_models_routes.py -v`
Expected: FAIL — `404 Not Found` for `/api/v1/status/models` (route does not exist yet).
- [ ] **Step 3: Write minimal implementation**
Replace the full contents of `backend/app/api/routes/status.py`:
```python
"""Define API routes for status."""
import asyncio
import time
from typing import Any
from fastapi import APIRouter
from app.config.settings import settings
from app.domain.retrieval import RetrievedChunk
from app.services.llm.llm_factory import get_llm_client
from app.shared.bootstrap import (
get_bm25_retriever,
get_binary_store,
get_conversation_store,
get_document_query_service,
get_embedding_provider,
get_reranker,
get_vector_index,
)
from app.shared.model_usage_tracker import get_model_usage_tracker
router = APIRouter(prefix="/status", tags=["系统状态"])
# ---------------------------------------------------------------------------
# Simple TTL cache for /stats (avoids O(N) doc scan on every request)
# ---------------------------------------------------------------------------
_stats_cache: dict[str, Any] = {}
_stats_cache_time: float = 0.0
_STATS_TTL_SECONDS: float = 10.0
# ---------------------------------------------------------------------------
# AI model roles surfaced on the Status page (Task: System Status AI models)
# ---------------------------------------------------------------------------
_MODEL_ROLES: dict[str, str] = {
"main_llm": "主问答 LLM",
"hyde_llm": "HyDE 查询增强",
"embedding": "Embedding",
"reranker": "Reranker",
}
@router.get("/stats")
async def get_stats():
"""Return document statistics (cached for 10 s)."""
global _stats_cache, _stats_cache_time
now = time.time()
if _stats_cache and (now - _stats_cache_time) < _STATS_TTL_SECONDS:
return _stats_cache
documents = get_document_query_service().list_documents()
indexed = sum(1 for d in documents if d.status.value == "indexed")
failed = sum(1 for d in documents if d.status.value == "failed")
_stats_cache = {
"documents_total": len(documents),
"documents_indexed": indexed,
"documents_failed": failed,
"chunks_total": sum(d.chunk_count for d in documents),
}
_stats_cache_time = now
return _stats_cache
@router.get("/config")
async def get_config():
"""Return system configuration."""
return {
"embedding_model": settings.embedding_model,
"embedding_dim": settings.embedding_dim,
"embedding_base_url": settings.embedding_base_url,
"milvus_collection": settings.milvus_collection,
"parser_backend": settings.parser_backend,
"chunk_backend": settings.chunk_backend,
"artifact_prefix": settings.document_parse_artifact_prefix,
"parser_failure_mode": settings.parser_failure_mode,
"llm_provider": settings.llm_provider,
"llm_model": settings.llm_model,
"document_metadata_path": settings.document_metadata_path,
}
@router.get("/milvus/health")
async def milvus_health():
"""Return Milvus health (kept for backwards compat)."""
return get_vector_index().health()
@router.get("/health")
async def get_health():
"""Return aggregate health of all backend services."""
# --- Milvus ---
try:
milvus_info = get_vector_index().health()
milvus_status = "ok" if milvus_info.get("connected") else "error"
except Exception as exc: # noqa: BLE001
milvus_info = {}
milvus_status = "error"
milvus_info["error"] = str(exc)
# --- MinIO ---
try:
minio_connected = get_binary_store().client.connected
minio_status = "ok" if minio_connected else "error"
except Exception: # noqa: BLE001
minio_status = "error"
minio_connected = False
# --- BM25 ---
bm25 = get_bm25_retriever()
# --- Sessions ---
try:
session_count = len(get_conversation_store().list_sessions())
except Exception: # noqa: BLE001
session_count = 0
return {
"milvus": {"status": milvus_status, **milvus_info},
"minio": {"status": minio_status, "connected": minio_connected},
"bm25": {"available": bm25 is not None},
"reranker": {
"enabled": settings.reranker_enabled,
"model": settings.reranker_model if settings.reranker_enabled else None,
},
"sessions": {
"active": session_count,
"max": settings.session_max_sessions,
},
}
def _resolve_role_provider_model(role: str) -> tuple[str, str]:
"""Return the (provider, model) pair currently configured for one AI model role.
For "hyde_llm" this mirrors the exact fallback logic already used in
hyde_expander.py (settings.hyde_llm_provider or settings.llm_provider, same
for model) so tracker lookups here always match what TrackedLLMClient
recorded when HyDE actually ran.
"""
if role == "main_llm":
return settings.llm_provider, settings.llm_model
if role == "hyde_llm":
return (
settings.hyde_llm_provider or settings.llm_provider,
settings.hyde_llm_model or settings.llm_model,
)
if role == "embedding":
return "embedding", settings.embedding_model
if role == "reranker":
return "reranker", settings.reranker_model
raise ValueError(f"unknown model role: {role}") # pragma: no cover - internal roles are fixed
def _build_model_status(role: str) -> dict[str, Any]:
"""Build one /status/models row for the given role from tracker data + live settings."""
provider, model = _resolve_role_provider_model(role)
entry = get_model_usage_tracker().get(provider, model)
main_provider, main_model = _resolve_role_provider_model("main_llm")
shares_usage_with = (
"main_llm" if role != "main_llm" and (provider, model) == (main_provider, main_model) else None
)
enabled = True
status = entry.status if entry else "never_called"
if role == "reranker":
enabled = settings.reranker_enabled
if not enabled:
# Config always wins: report "disabled" even if the reranker was
# enabled and called successfully earlier in this process's life.
status = "disabled"
return {
"role": role,
"role_label": _MODEL_ROLES[role],
"provider": provider,
"model": model,
"enabled": enabled,
"status": status,
"total_tokens": entry.total_tokens if entry else 0,
"call_count_ok": entry.call_count_ok if entry else 0,
"call_count_error": entry.call_count_error if entry else 0,
"last_called_at": entry.last_called_at.isoformat() if entry and entry.last_called_at else None,
"last_latency_ms": entry.last_latency_ms if entry else None,
"last_error": entry.last_error if entry else None,
"shares_usage_with": shares_usage_with,
}
@router.get("/models")
async def get_model_statuses():
"""Return connection status + cumulative token usage for all 4 tracked AI model roles.
Passive: reads tracker state + settings only, makes no outbound network calls.
"""
return {"models": [_build_model_status(role) for role in _MODEL_ROLES]}
async def _ping_main_or_hyde(role: str) -> None:
"""Send one minimal chat completion to the LLM configured for `role`."""
provider, model = _resolve_role_provider_model(role)
client = get_llm_client(provider=provider, model=model)
await asyncio.to_thread(client.chat, [{"role": "user", "content": "ping"}], max_tokens=1)
async def _ping_embedding() -> None:
"""Send one minimal embedding request."""
await asyncio.to_thread(get_embedding_provider().embed_query, "ping")
async def _ping_reranker() -> None:
"""Send one minimal rerank request, only when the reranker is enabled."""
reranker = get_reranker()
if reranker is None:
return
# Minimal single-chunk probe — real content doesn't matter, only round-trip success.
placeholder = RetrievedChunk(chunk_id="ping", doc_id="ping", doc_title="ping", text="ping", score=0.0)
await asyncio.to_thread(reranker.rerank, "ping", [placeholder], 1)
@router.post("/models/ping")
async def ping_model_connections():
"""Actively test each configured model with a minimal request, then return fresh statuses.
Each ping is isolated with return_exceptions=True so one model timing out
or erroring does not prevent the other three from completing and being
reported. Failures are still visible afterwards via _build_model_status()
because the underlying clients record their own outcome into the tracker.
"""
tasks = [
_ping_main_or_hyde("main_llm"),
_ping_main_or_hyde("hyde_llm"),
_ping_embedding(),
_ping_reranker(),
]
await asyncio.gather(*tasks, return_exceptions=True)
return {"models": [_build_model_status(role) for role in _MODEL_ROLES]}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_status_models_routes.py -v`
Expected: PASS (4 tests)
Then run the existing status-adjacent tests to confirm no regressions (auth routes still import `app.api.main` the same way):
Run: `uv run pytest tests/test_auth_routes.py backend/tests/observability/ tests/test_status_models_routes.py -v`
Expected: PASS (all)
- [ ] **Step 5: Commit**
```bash
git add backend/app/api/routes/status.py tests/test_status_models_routes.py
git commit -m "feat: add GET/POST /status/models routes for AI model connection status"
```
---
### Task 7: Frontend — i18n keys + `.status.error` CSS fix
**Files:**
- Modify: `frontend/src/locales/en.ts:2-134` (interface) and `:349-394` (value object)
- Modify: `frontend/src/locales/zh.ts:90-135` (value object)
- Modify: `frontend/src/styles/globals.css:133-140`
**Interfaces:**
- Produces: new `Translations.status.*` keys (`cardModels`, `testConnectionBtn`, `testingBtn`, `roleMainLlm`, `roleHydeLlm`, `roleEmbedding`, `roleReranker`, `modelStatusNeverCalled`, `modelStatusDisabled`, `sharesUsageWithMain`, `lastCalledNever`) consumed by Task 9. New CSS rule `.status.error` (this repo's `StatusIcon`/badge components already emit `className="status error"` for error states, e.g. Milvus health, but only `.status.ok/.warn/.risk/.info` exist today — `.error` silently renders uncolored; this new card reuses that exact badge pattern, so the gap is fixed here rather than shipped a second time).
- [ ] **Step 1: Add the type declarations (this "fails" the TypeScript build until Step 3 supplies matching values)**
In `frontend/src/locales/en.ts`, in the `export interface Translations` block, inside the `status: { ... }` section, insert after the existing `totalChunks: string;` line (line 133):
```typescript
totalChunks: string;
cardModels: string;
testConnectionBtn: string;
testingBtn: string;
roleMainLlm: string;
roleHydeLlm: string;
roleEmbedding: string;
roleReranker: string;
modelStatusNeverCalled: string;
modelStatusDisabled: string;
sharesUsageWithMain: string;
lastCalledNever: string;
};
```
(This replaces the original `totalChunks: string;\n };` two-line ending of the `status` type block with the expanded version above.)
- [ ] **Step 2: Run the frontend build to verify it fails**
Run: `npm --prefix frontend run build`
Expected: FAIL — TypeScript error `Property 'cardModels' is missing in type '{ ... }' but required in type 'Translations'` (or similar), pointing at `zh.ts`'s `export const zh: Translations = {...}` object, because `zh.ts` does not yet have these keys.
- [ ] **Step 3: Supply matching values in both locale files**
In `frontend/src/locales/en.ts`, in the `export const en: Translations = {...}` value object, inside its `status: { ... }` section, insert after the existing `totalChunks: 'Total vector chunks',` line (line 393):
```typescript
totalChunks: 'Total vector chunks',
cardModels: 'AI Models',
testConnectionBtn: 'Test connection',
testingBtn: 'Testing…',
roleMainLlm: 'Main answer LLM',
roleHydeLlm: 'HyDE query expansion',
roleEmbedding: 'Embedding',
roleReranker: 'Reranker',
modelStatusNeverCalled: 'Not called yet',
modelStatusDisabled: 'Disabled',
sharesUsageWithMain: 'Shares usage with main LLM',
lastCalledNever: 'Never',
},
```
In `frontend/src/locales/zh.ts`, in the `export const zh: Translations = {...}` value object, inside its `status: { ... }` section, insert after the existing `totalChunks: '向量分块总数',` line (line 134):
```typescript
totalChunks: '向量分块总数',
cardModels: 'AI 模型',
testConnectionBtn: '测试连接',
testingBtn: '测试中…',
roleMainLlm: '主问答 LLM',
roleHydeLlm: 'HyDE 查询增强',
roleEmbedding: 'Embedding',
roleReranker: 'Reranker',
modelStatusNeverCalled: '尚未调用',
modelStatusDisabled: '已禁用',
sharesUsageWithMain: '与主 LLM 共用统计',
lastCalledNever: '从未',
},
```
- [ ] **Step 4: Run the frontend build to verify it passes**
Run: `npm --prefix frontend run build`
Expected: PASS (TypeScript compiles; the two locale files now satisfy `Translations`)
- [ ] **Step 5: Add the missing `.status.error` CSS rule**
In `frontend/src/styles/globals.css`, after the existing block (lines 137-138):
```css
.status.risk { color: var(--danger); background: var(--danger-bg); }
.status.risk::before { background: var(--danger); }
```
insert:
```css
.status.error { color: var(--danger); background: var(--danger-bg); }
.status.error::before { background: var(--danger); }
```
- [ ] **Step 6: Run lint + build once more to confirm nothing broke**
Run: `npm --prefix frontend run lint`
Expected: PASS (no new errors)
Run: `npm --prefix frontend run build`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add frontend/src/locales/en.ts frontend/src/locales/zh.ts frontend/src/styles/globals.css
git commit -m "feat: add i18n keys for AI Models card and fix missing .status.error CSS"
```
---
### Task 8: Frontend — API client layer (`api/index.ts`, `api/status.ts`)
**Files:**
- Modify: `frontend/src/api/index.ts:279-313` (add types before the closing `export { API_BASE_URL };`)
- Modify: `frontend/src/api/status.ts` (full file, 16 lines)
**Interfaces:**
- Consumes: `fetchAPI` (`frontend/src/api/index.ts`, existing), the `GET /status/models` / `POST /status/models/ping` response shape (Task 6).
- Produces: `ModelUsageEntry` / `ModelUsageResponse` types; `getModelUsage(): Promise<ModelUsageResponse>`; `pingModelConnections(): Promise<ModelUsageResponse>` — consumed by Task 9.
- [ ] **Step 1: Add types to `frontend/src/api/index.ts`**
Insert before the final `export { API_BASE_URL };` line:
```typescript
export type ModelRole = 'main_llm' | 'hyde_llm' | 'embedding' | 'reranker';
export type ModelStatus = 'ok' | 'error' | 'never_called' | 'disabled';
export interface ModelUsageEntry {
role: ModelRole;
role_label: string;
provider: string;
model: string;
enabled: boolean;
status: ModelStatus;
total_tokens: number;
call_count_ok: number;
call_count_error: number;
last_called_at: string | null;
last_latency_ms: number | null;
last_error: string | null;
shares_usage_with: ModelRole | null;
}
export interface ModelUsageResponse {
models: ModelUsageEntry[];
}
```
- [ ] **Step 2: Add functions to `frontend/src/api/status.ts`**
Replace the full contents of `frontend/src/api/status.ts`:
```typescript
import { fetchAPI, type ModelUsageResponse, type SystemConfig, type SystemHealth, type SystemStats } from './index';
export async function getSystemStats(): Promise<SystemStats> {
return fetchAPI<SystemStats>('/status/stats');
}
export async function getSystemConfig(): Promise<SystemConfig> {
return fetchAPI<SystemConfig>('/status/config');
}
export async function getSystemHealth(): Promise<SystemHealth> {
return fetchAPI<SystemHealth>('/status/health');
}
/** Passive read: current connection status + cumulative token usage for all 4 AI model roles. */
export async function getModelUsage(): Promise<ModelUsageResponse> {
return fetchAPI<ModelUsageResponse>('/status/models');
}
/** Active check: sends one minimal request to each enabled model, then returns fresh statuses. */
export async function pingModelConnections(): Promise<ModelUsageResponse> {
return fetchAPI<ModelUsageResponse>('/status/models/ping', { method: 'POST' });
}
export type { ModelUsageResponse, SystemConfig, SystemHealth, SystemStats };
```
- [ ] **Step 3: Run the frontend build to verify it compiles**
Run: `npm --prefix frontend run build`
Expected: PASS (new exports compile; nothing consumes them yet, which is fine — Task 9 wires them in)
- [ ] **Step 4: Commit**
```bash
git add frontend/src/api/index.ts frontend/src/api/status.ts
git commit -m "feat: add ModelUsageEntry types and status API client functions"
```
---
### Task 9: Frontend — "AI Models" card on `StatusPage.tsx`
**Files:**
- Modify: `frontend/src/pages/Status/StatusPage.tsx` (full file, 366 lines)
**Interfaces:**
- Consumes: `getModelUsage()`, `pingModelConnections()`, `ModelUsageEntry`, `ModelUsageResponse` (Task 8); `t.status.cardModels` etc. (Task 7); existing `StatusIcon` component (defined in this same file) and `.service-row`/`.status.*` CSS classes.
- [ ] **Step 1: Add the import and new state**
At the top of `frontend/src/pages/Status/StatusPage.tsx`, change:
```typescript
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
import { UploadModal } from '../Docs/UploadModal';
import { useLanguage } from '../../contexts/LanguageContext';
```
to:
```typescript
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
import { UploadModal } from '../Docs/UploadModal';
import { useLanguage } from '../../contexts/LanguageContext';
import { getModelUsage, pingModelConnections } from '../../api/status';
import type { ModelUsageEntry } from '../../api/index';
```
(Confirmed against the existing sibling import in `frontend/src/pages/RagChat/RagChatPage.tsx:8``import type { SSEMessage } from '../../api/index';` — this file uses the explicit `/index` suffix, not the bare `'../../api'` directory import, so the line above matches established convention exactly.)
Inside `export function StatusPage()`, after the existing `const [lastRefresh, setLastRefresh] = useState<Date | null>(null);` line, add:
```typescript
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
const [pinging, setPinging] = useState(false);
```
- [ ] **Step 2: Fetch model usage alongside the other 3 endpoints**
Change the existing `useEffect` fetch block from:
```typescript
// Fetch all three endpoints in parallel
Promise.allSettled([
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
]).then(([statsRes, healthRes, configRes]) => {
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
if (configRes.status === 'fulfilled') setConfig(configRes.value);
setLoading(false);
setHealthLoading(false);
setLastRefresh(new Date());
});
```
to:
```typescript
// Fetch all endpoints in parallel. The first three use raw fetch() (legacy
// pattern already established in this file); model usage uses the typed
// fetchAPI-based client from api/status.ts — new code should prefer that.
Promise.allSettled([
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
getModelUsage(),
]).then(([statsRes, healthRes, configRes, modelsRes]) => {
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
if (configRes.status === 'fulfilled') setConfig(configRes.value);
if (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models);
else setModelUsage(null);
setLoading(false);
setHealthLoading(false);
setLastRefresh(new Date());
});
```
- [ ] **Step 3: Add the "test connection" handler**
After the existing `function handleExport() { ... }` function, add:
```typescript
async function handleTestConnections() {
setPinging(true);
try {
const res = await pingModelConnections();
setModelUsage(res.models);
} catch {
// Leave modelUsage as-is; the card below already shows a muted
// "never_called"/error state per row when data can't be refreshed.
} finally {
setPinging(false);
}
}
function modelBadgeStatus(status: ModelUsageEntry['status']): 'ok' | 'error' | 'warn' | 'info' {
if (status === 'ok') return 'ok';
if (status === 'error') return 'error';
if (status === 'disabled') return 'info';
return 'info'; // never_called
}
function modelStatusLabel(entry: ModelUsageEntry): string {
if (entry.status === 'never_called') return t.status.modelStatusNeverCalled;
if (entry.status === 'disabled') return t.status.modelStatusDisabled;
return entry.status === 'ok' ? t.status.badgeOnline : t.status.badgeError;
}
/** Small relative-ish hint shown next to provider/model — "Never" or a local time string. */
function modelLastCalledLabel(entry: ModelUsageEntry): string {
if (!entry.last_called_at) return t.status.lastCalledNever;
return new Date(entry.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
```
- [ ] **Step 4: Render the new card**
Immediately after the closing `</div>` of the existing "System health" card (the `<div className="card">` block containing `<span>{t.status.cardHealth}</span>` — locate it by searching for `cardHealth` in this file) and before the "System config (collapsible)" card's opening `<div className="card">`, insert:
```typescript
{/* AI Models — connection status + cumulative token usage */}
<div className="card">
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{t.status.cardModels}</span>
<button className="btn sm" onClick={handleTestConnections} disabled={pinging}>
{pinging ? t.status.testingBtn : t.status.testConnectionBtn}
</button>
</div>
{!modelUsage ? (
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />
))}
</div>
) : (
modelUsage.map(entry => {
const roleLabel = entry.role === 'main_llm' ? t.status.roleMainLlm
: entry.role === 'hyde_llm' ? t.status.roleHydeLlm
: entry.role === 'embedding' ? t.status.roleEmbedding
: t.status.roleReranker;
return (
<div className="service-row" key={entry.role}>
<StatusIcon status={modelBadgeStatus(entry.status)} />
<span className="service-name" style={{ marginLeft: 8 }}>{roleLabel}</span>
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)' }}>
{entry.provider}/{entry.model}
{entry.shares_usage_with && ` · ${t.status.sharesUsageWithMain}`}
{` · ${modelLastCalledLabel(entry)}`}
</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg)' }}>
{entry.total_tokens > 0 || entry.status === 'ok' || entry.status === 'error'
? entry.total_tokens.toLocaleString()
: '—'}
</span>
<span className={`status ${modelBadgeStatus(entry.status)}`} style={{ marginLeft: 8 }}>
{modelStatusLabel(entry)}
</span>
</div>
);
})
)}
</div>
```
- [ ] **Step 5: Remove the now-duplicate reranker line from the "Runtime" card**
In the "Sessions & reranker quick facts" card (search for `cardRuntime` in this file), remove this block:
```typescript
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>{t.status.labelReranker}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
{health.reranker.enabled ? (health.reranker.model ?? t.status.serviceEnabled) : t.status.serviceDisabled}
</span>
</div>
```
(leaving `labelActiveSessions`, `labelSessionCapacity`, and `labelBM25` rows in place — only the reranker row is removed, since the new "AI Models" card now shows it with richer detail.)
- [ ] **Step 6: Run lint + build + manual check**
Run: `npm --prefix frontend run lint`
Expected: PASS
Run: `npm --prefix frontend run build`
Expected: PASS
Run: `npm --prefix frontend run dev` (or `./dev.sh start frontend --foreground` per `AGENTS.md`), open the Status page in a browser, and manually verify:
- The new "AI Models" card renders 4 rows (main LLM, HyDE, Embedding, Reranker) without console errors.
- Clicking "Test connection" shows "Testing…" then updates the rows (requires a reachable backend + gateway; if the gateway is unreachable, at least confirm the button doesn't crash the page and an error-status row renders in red, validating the CSS fix from Task 7).
- The "Runtime" card no longer shows a separate Reranker line.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/pages/Status/StatusPage.tsx
git commit -m "feat: add AI Models card to Status page with connection test button"
```
---
## Self-Review
**1. Spec coverage:**
- Tracker keyed by provider:model, never-raises → Task 1. ✅
- TrackedLLMClient wrapping every call site via the factory → Tasks 2-3. ✅
- Embedding usage capture, reranker call-only tracking → Tasks 4-5. ✅
- `GET /status/models` (passive) + `POST /status/models/ping` (active, parallel, partial-failure-safe) → Task 6. ✅
- Frontend card, "shares usage with main LLM" note, removed duplicate Runtime reranker line, i18n, desktop-only → Tasks 7-9. ✅
- Known limitation (streaming tokens not counted) → encoded directly in `TrackedLLMClient.stream_chat()`'s docstring/comment (Task 2). ✅
- `.status.error` CSS gap → Task 7, Step 5. ✅
- No new DB table, no cost estimation, no per-session breakdown → correctly absent from every task. ✅
**2. Placeholder scan:** No "TBD"/"TODO"/"add appropriate handling" phrases in any step; every code block is complete, runnable code; every test has real assertions. Task 9's import path was independently verified against `frontend/src/pages/RagChat/RagChatPage.tsx:8` (an existing sibling file) rather than left as a "verify this" note for the executor.
**3. Type consistency:**
- `ModelUsageEntry.status` values (`never_called|ok|error`) plus the route's `disabled` override are consistently referenced as the 4-value `ModelStatus` union across Task 1 (Python), Task 6 (route), and Task 8 (TypeScript type) — checked field-by-field.
- `ModelUsageTracker.record()`'s keyword-only signature (`provider, model, success, usage=None, latency_ms=None, error=None`) is called identically in Tasks 2, 4, 5, and matches Task 1's definition exactly (checked argument names one by one).
- `TrackedLLMClient` is used, not subclassed, everywhere (Task 3's `llm_factory.py` return-type annotations updated to the union `BaseLLMClient | TrackedLLMClient` consistently in `create()`, `get_cached()`, and `get_llm_client()`).
- Frontend `ModelUsageEntry`/`ModelUsageResponse` (Task 8) field names match the backend route's dict keys (Task 6) one-to-one: `role, role_label, provider, model, enabled, status, total_tokens, call_count_ok, call_count_error, last_called_at, last_latency_ms, last_error, shares_usage_with`.
- i18n keys referenced in Task 9's JSX (`t.status.cardModels`, `testConnectionBtn`, `testingBtn`, `roleMainLlm`, `roleHydeLlm`, `roleEmbedding`, `roleReranker`, `modelStatusNeverCalled`, `modelStatusDisabled`, `sharesUsageWithMain`, `lastCalledNever`) all match keys added in Task 7 exactly (checked name-by-name) and all are actually rendered — the earlier draft had an unused `totalTokensLabel` key and omitted the spec's "last-called relative-time hint"; both are fixed: the unused key was removed from Task 7, and Task 9 now renders `modelLastCalledLabel()` (using `lastCalledNever`) inline next to provider/model.