xueyiming 1 неделя назад
Родитель
Сommit
e30cc31793

+ 2 - 0
api/app.py

@@ -15,6 +15,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 
 
 from api.auth_middleware import AuthenticationMiddleware
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
 from api.routers.auth import router as auth_router
+from api.routers.llm_billing import router as llm_billing_router
 from api.routers.pipeline import router as pipeline_router
 from api.routers.pipeline import router as pipeline_router
 from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.agent_catalog import (
 from api.services.agent_catalog import (
@@ -86,6 +87,7 @@ async def lifespan(_app: FastAPI):
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
 app.include_router(auth_router)
 app.include_router(auth_router)
 app.include_router(pipeline_router)
 app.include_router(pipeline_router)
+app.include_router(llm_billing_router)
 
 
 app.add_middleware(
 app.add_middleware(
     CORSMiddleware,
     CORSMiddleware,

+ 6 - 2
supply_agent/llm/client.py

@@ -119,7 +119,9 @@ class LLMClient:
         result = self._parse_response(raw_message)
         result = self._parse_response(raw_message)
 
 
         if self.logger:
         if self.logger:
-            self.logger.log_llm_output(iteration, result, raw_response=response)
+            self.logger.log_llm_output(
+                iteration, result, raw_response=response, model=self.model
+            )
 
 
         return result
         return result
 
 
@@ -175,7 +177,9 @@ class LLMClient:
         result = self._parse_response(raw_message)
         result = self._parse_response(raw_message)
 
 
         if self.logger:
         if self.logger:
-            self.logger.log_llm_output(iteration, result, raw_response=response)
+            self.logger.log_llm_output(
+                iteration, result, raw_response=response, model=self.model
+            )
 
 
         return result
         return result
 
 

+ 8 - 0
supply_agent/logging/__init__.py

@@ -7,6 +7,11 @@ from supply_agent.logging.publish import (
     publish_run_artifacts,
     publish_run_artifacts,
     set_run_artifact_publisher,
     set_run_artifact_publisher,
 )
 )
+from supply_agent.logging.usage import (
+    get_llm_usage_recorder,
+    record_llm_usage,
+    set_llm_usage_recorder,
+)
 from supply_agent.logging.visualize import generate_visualization, render_html
 from supply_agent.logging.visualize import generate_visualization, render_html
 
 
 __all__ = [
 __all__ = [
@@ -18,6 +23,9 @@ __all__ = [
     "publish_run_artifacts",
     "publish_run_artifacts",
     "set_run_artifact_publisher",
     "set_run_artifact_publisher",
     "get_run_artifact_publisher",
     "get_run_artifact_publisher",
+    "record_llm_usage",
+    "set_llm_usage_recorder",
+    "get_llm_usage_recorder",
     "generate_visualization",
     "generate_visualization",
     "render_html",
     "render_html",
 ]
 ]

+ 51 - 0
supply_agent/logging/logger.py

@@ -94,6 +94,7 @@ class AgentLogger:
         self._seq: int = 0
         self._seq: int = 0
         self._jsonl_fh: Any | None = None
         self._jsonl_fh: Any | None = None
         self._agent_name: str | None = None
         self._agent_name: str | None = None
+        self._last_model: str | None = None
 
 
     @property
     @property
     def run_id(self) -> str | None:
     def run_id(self) -> str | None:
@@ -103,6 +104,10 @@ class AgentLogger:
     def agent_name(self) -> str | None:
     def agent_name(self) -> str | None:
         return self._agent_name
         return self._agent_name
 
 
+    @property
+    def last_model(self) -> str | None:
+        return self._last_model
+
     @property
     @property
     def log_file(self) -> Path | None:
     def log_file(self) -> Path | None:
         return self._log_file
         return self._log_file
@@ -127,6 +132,7 @@ class AgentLogger:
         short_id = uuid.uuid4().hex[:8]
         short_id = uuid.uuid4().hex[:8]
         slug = _slugify_agent_name(agent_name)
         slug = _slugify_agent_name(agent_name)
         self._agent_name = agent_name
         self._agent_name = agent_name
+        self._last_model = model
         if slug:
         if slug:
             self._run_id = f"{slug}_{timestamp}_{short_id}"
             self._run_id = f"{slug}_{timestamp}_{short_id}"
         else:
         else:
@@ -178,6 +184,7 @@ class AgentLogger:
         if not self.enabled or not self._logger:
         if not self.enabled or not self._logger:
             return
             return
 
 
+        self._last_model = model
         payload = {
         payload = {
             "iteration": iteration,
             "iteration": iteration,
             "model": model,
             "model": model,
@@ -199,6 +206,9 @@ class AgentLogger:
         iteration: int,
         iteration: int,
         response: Message,
         response: Message,
         raw_response: Any | None = None,
         raw_response: Any | None = None,
+        *,
+        model: str | None = None,
+        provider: str = "openrouter",
     ) -> None:
     ) -> None:
         """Log the complete LLM response, highlighting reasoning and tool calls."""
         """Log the complete LLM response, highlighting reasoning and tool calls."""
         if not self.enabled or not self._logger:
         if not self.enabled or not self._logger:
@@ -207,9 +217,13 @@ class AgentLogger:
         parsed = response.model_dump()
         parsed = response.model_dump()
         tool_calls = parsed.get("tool_calls") or []
         tool_calls = parsed.get("tool_calls") or []
         usage = _extract_usage(raw_response)
         usage = _extract_usage(raw_response)
+        resolved_model = model or self._extract_model(raw_response) or self._last_model
+        if resolved_model:
+            self._last_model = resolved_model
 
 
         payload: dict[str, Any] = {
         payload: dict[str, Any] = {
             "iteration": iteration,
             "iteration": iteration,
+            "model": resolved_model,
             "content": parsed.get("content"),
             "content": parsed.get("content"),
             "reasoning": parsed.get("reasoning"),
             "reasoning": parsed.get("reasoning"),
             "tool_calls": tool_calls,
             "tool_calls": tool_calls,
@@ -230,6 +244,12 @@ class AgentLogger:
             iteration=iteration,
             iteration=iteration,
             title=f"LLM OUTPUT | step={self._seq + 1} | iteration={iteration}",
             title=f"LLM OUTPUT | step={self._seq + 1} | iteration={iteration}",
         )
         )
+        self._record_usage(
+            iteration=iteration,
+            model=resolved_model,
+            usage=usage,
+            provider=provider,
+        )
 
 
     def log_tool_call(
     def log_tool_call(
         self,
         self,
@@ -305,6 +325,37 @@ class AgentLogger:
             self._jsonl_fh.close()
             self._jsonl_fh.close()
             self._jsonl_fh = None
             self._jsonl_fh = None
 
 
+    def _extract_model(self, raw_response: Any) -> str | None:
+        if raw_response is None:
+            return None
+        model = getattr(raw_response, "model", None)
+        if model is None and isinstance(raw_response, dict):
+            model = raw_response.get("model")
+        return str(model) if model else None
+
+    def _record_usage(
+        self,
+        *,
+        iteration: int,
+        model: str | None,
+        usage: dict[str, Any] | None,
+        provider: str,
+    ) -> None:
+        if not usage:
+            return
+        from supply_agent.logging.usage import record_llm_usage
+
+        record_llm_usage(
+            {
+                "run_id": self._run_id,
+                "agent_name": self._agent_name,
+                "model": model,
+                "provider": provider,
+                "iteration": iteration,
+                "usage": usage,
+            }
+        )
+
     def _emit(
     def _emit(
         self,
         self,
         event: str,
         event: str,

+ 4 - 1
supply_infra/agent_logging/register.py

@@ -4,8 +4,11 @@ from __future__ import annotations
 
 
 
 
 def register_agent_logging_hooks() -> None:
 def register_agent_logging_hooks() -> None:
-    """Wire OSS publish into Agent._finish_run (safe to call repeatedly)."""
+    """Wire OSS publish + LLM billing into agent hooks (safe to call repeatedly)."""
     from supply_agent.logging.publish import set_run_artifact_publisher
     from supply_agent.logging.publish import set_run_artifact_publisher
+    from supply_agent.logging.usage import set_llm_usage_recorder
     from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
     from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
+    from supply_infra.llm_billing.recorder import record_llm_usage_to_db
 
 
     set_run_artifact_publisher(publish_run_artifacts_to_oss)
     set_run_artifact_publisher(publish_run_artifacts_to_oss)
+    set_llm_usage_recorder(record_llm_usage_to_db)

+ 2 - 0
supply_infra/db/models/__init__.py

@@ -25,6 +25,7 @@ from supply_infra.db.models.global_tree_element import GlobalTreeElement
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
 from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
+from supply_infra.db.models.llm_usage_event import LlmUsageEvent
 from supply_infra.db.models.oss_log import OssLog
 from supply_infra.db.models.oss_log import OssLog
 from supply_infra.db.models.pipeline_lock import PipelineLock
 from supply_infra.db.models.pipeline_lock import PipelineLock
 from supply_infra.db.models.pipeline_outbox import PipelineOutbox
 from supply_infra.db.models.pipeline_outbox import PipelineOutbox
@@ -58,6 +59,7 @@ __all__ = [
     "MultiDemandPoolDi",
     "MultiDemandPoolDi",
     "MultiDemandVideoDetail",
     "MultiDemandVideoDetail",
     "MultiDemandVideoPoint",
     "MultiDemandVideoPoint",
+    "LlmUsageEvent",
     "OssLog",
     "OssLog",
     "PipelineLock",
     "PipelineLock",
     "PipelineOutbox",
     "PipelineOutbox",

+ 2 - 0
supply_infra/db/repositories/__init__.py

@@ -37,6 +37,7 @@ from supply_infra.db.repositories.multi_demand_video_detail_repo import (
 from supply_infra.db.repositories.multi_demand_video_point_repo import (
 from supply_infra.db.repositories.multi_demand_video_point_repo import (
     MultiDemandVideoPointRepository,
     MultiDemandVideoPointRepository,
 )
 )
+from supply_infra.db.repositories.llm_usage_repo import LlmUsageRepository
 from supply_infra.db.repositories.oss_log_repo import OssLogRepository
 from supply_infra.db.repositories.oss_log_repo import OssLogRepository
 from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
 from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
 from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
 from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
@@ -64,6 +65,7 @@ __all__ = [
     "MultiDemandPoolDiRepository",
     "MultiDemandPoolDiRepository",
     "MultiDemandVideoDetailRepository",
     "MultiDemandVideoDetailRepository",
     "MultiDemandVideoPointRepository",
     "MultiDemandVideoPointRepository",
+    "LlmUsageRepository",
     "OssLogRepository",
     "OssLogRepository",
     "PipelineLockRepository",
     "PipelineLockRepository",
     "PipelineOutboxRepository",
     "PipelineOutboxRepository",

+ 8 - 0
tests/supply_agent/test_publish_hook.py

@@ -52,13 +52,21 @@ def test_set_run_artifact_publisher_is_invoked() -> None:
 
 
 def test_infra_registers_publisher_hook() -> None:
 def test_infra_registers_publisher_hook() -> None:
     previous = get_run_artifact_publisher()
     previous = get_run_artifact_publisher()
+    from supply_agent.logging.usage import get_llm_usage_recorder, set_llm_usage_recorder
+
+    previous_usage = get_llm_usage_recorder()
     try:
     try:
         set_run_artifact_publisher(None)
         set_run_artifact_publisher(None)
+        set_llm_usage_recorder(None)
         import supply_infra  # noqa: F401
         import supply_infra  # noqa: F401
         from supply_infra.agent_logging.register import register_agent_logging_hooks
         from supply_infra.agent_logging.register import register_agent_logging_hooks
         from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
         from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
+        from supply_infra.llm_billing.recorder import record_llm_usage_to_db
 
 
         register_agent_logging_hooks()
         register_agent_logging_hooks()
         assert get_run_artifact_publisher() is publish_run_artifacts_to_oss
         assert get_run_artifact_publisher() is publish_run_artifacts_to_oss
+        assert get_llm_usage_recorder() is record_llm_usage_to_db
     finally:
     finally:
         set_run_artifact_publisher(previous)
         set_run_artifact_publisher(previous)
+        set_llm_usage_recorder(previous_usage)
+

+ 1 - 0
web/src/App.vue

@@ -15,6 +15,7 @@ const adminNavItems = [
   { to: '/demand-map', label: '全局需求地图', icon: '⌁', admin: false },
   { to: '/demand-map', label: '全局需求地图', icon: '⌁', admin: false },
   { to: '/video-discovery', label: '需求汇总', icon: '▷', admin: false },
   { to: '/video-discovery', label: '需求汇总', icon: '▷', admin: false },
   { to: '/pipeline-runs', label: '定时任务', icon: '◷', admin: true },
   { to: '/pipeline-runs', label: '定时任务', icon: '◷', admin: true },
+  { to: '/llm-billing', label: 'LLM 费用', icon: '$', admin: true },
   { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
   { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
   { to: '/find-agent-records', label: '找视频记录', icon: '⌕', admin: false },
   { to: '/find-agent-records', label: '找视频记录', icon: '⌕', admin: false },
   { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },
   { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },

+ 7 - 0
web/src/router.ts

@@ -7,6 +7,7 @@ import FindAgentRecordsView from './views/FindAgentRecordsView.vue'
 import LoginView from './views/LoginView.vue'
 import LoginView from './views/LoginView.vue'
 import OverviewView from './views/OverviewView.vue'
 import OverviewView from './views/OverviewView.vue'
 import PipelineRunsView from './views/PipelineRunsView.vue'
 import PipelineRunsView from './views/PipelineRunsView.vue'
+import LlmBillingView from './views/LlmBillingView.vue'
 import UserManagementView from './views/UserManagementView.vue'
 import UserManagementView from './views/UserManagementView.vue'
 import VideoDiscoveryView from './views/VideoDiscoveryView.vue'
 import VideoDiscoveryView from './views/VideoDiscoveryView.vue'
 import { defaultRouteFor, loadCurrentUser } from './auth'
 import { defaultRouteFor, loadCurrentUser } from './auth'
@@ -62,6 +63,12 @@ export const router = createRouter({
       component: PipelineRunsView,
       component: PipelineRunsView,
       meta: { title: '定时任务运行中心', admin: true },
       meta: { title: '定时任务运行中心', admin: true },
     },
     },
+    {
+      path: '/llm-billing',
+      name: 'llm-billing',
+      component: LlmBillingView,
+      meta: { title: 'LLM 费用', admin: true },
+    },
     {
     {
       path: '/admin/users',
       path: '/admin/users',
       name: 'admin-users',
       name: 'admin-users',