Files
taiji-AI-PAD/plans/LiteLLM-Callback计费方案实施指南.md
T
2026-01-09 06:56:43 +00:00

29 KiB
Raw Blame History

LiteLLM Callback 计费方案实施指南

版本: v1.0.0
创建时间: 2026-01-08
目标: 实现基于 LiteLLM Custom Callback 的实时模型 Token 计费


1. 方案概述

1.1 架构设计

sequenceDiagram
    participant Agent as Agent/租户应用
    participant LiteLLM as LiteLLM Gateway
    participant MCP as mcp-server
    participant DB as PostgreSQL

    Agent->>LiteLLM: 1. 调用模型 API
    LiteLLM->>LiteLLM: 2. 执行模型调用
    LiteLLM-->>Agent: 3. 返回响应
    LiteLLM->>MCP: 4. POST /api/v1/billing/litellm-callback
    Note over LiteLLM,MCP: 异步回调,包含 Token 用量
    MCP->>MCP: 5. 解析回调数据
    MCP->>DB: 6. 写入 model_billing_records
    MCP->>DB: 7. 更新用户 EU 余额
    MCP-->>LiteLLM: 8. 返回 200 OK

1.2 核心优势

特性 说明
实时性 每次模型调用后立即回调,无延迟
准确性 直接从 LiteLLM 获取精确的 Token 用量
可靠性 LiteLLM 内置重试机制
解耦性 mcp-server 无需轮询,被动接收数据

2. 实施步骤清单

2.1 总体任务

  • Step 1: 创建数据库表 model_billing_records
  • Step 2: 创建 Pydantic Schema 定义
  • Step 3: 创建 Webhook 路由 /api/v1/billing/litellm-callback
  • Step 4: 实现计费逻辑(Token → EU 转换)
  • Step 5: 配置 LiteLLM Custom Callback
  • Step 6: 修改 Dashboard 查询逻辑
  • Step 7: 测试和验证

3. 详细实施方案

3.1 Step 1: 数据库迁移

文件: services/mcp-server/migrations/012_add_model_billing_records.sql

-- 模型调用计费记录表
-- 存储 LiteLLM Callback 推送的 Token 用量数据

CREATE TABLE IF NOT EXISTS model_billing_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    
    -- 关联信息(从 LiteLLM metadata 解析)
    tenant_id UUID REFERENCES users(id),
    channel_id UUID REFERENCES channels(id),
    
    -- LiteLLM 回调数据
    litellm_call_id VARCHAR(100) UNIQUE NOT NULL,  -- LiteLLM 调用 ID
    api_key VARCHAR(255),  -- 使用的 API Key(脱敏)
    team_id VARCHAR(100),  -- LiteLLM Team ID
    
    -- 模型信息
    model_name VARCHAR(100) NOT NULL,  -- 如 azure/gpt-4
    model_id VARCHAR(100),  -- LiteLLM 内部模型 ID
    
    -- Token 用量
    input_tokens INTEGER NOT NULL DEFAULT 0,
    output_tokens INTEGER NOT NULL DEFAULT 0,
    total_tokens INTEGER NOT NULL DEFAULT 0,
    
    -- 费用计算
    input_cost NUMERIC(12, 6) DEFAULT 0,  -- 输入成本(美元)
    output_cost NUMERIC(12, 6) DEFAULT 0,  -- 输出成本(美元)
    total_cost NUMERIC(12, 6) DEFAULT 0,  -- 总成本(美元)
    eu_consumed NUMERIC(12, 4) DEFAULT 0,  -- EU 消耗
    
    -- 调用信息
    request_id VARCHAR(100),  -- 请求 ID
    call_type VARCHAR(50) DEFAULT 'completion',  -- completion, embedding, etc.
    status VARCHAR(20) DEFAULT 'success',  -- success, error
    
    -- 时间信息
    start_time TIMESTAMP WITH TIME ZONE,
    end_time TIMESTAMP WITH TIME ZONE,
    response_time_ms INTEGER,  -- 响应时间(毫秒)
    
    -- 原始数据(用于调试)
    raw_callback_data JSONB,
    
    -- 索引
    CONSTRAINT idx_model_billing_tenant UNIQUE (tenant_id, litellm_call_id)
);

-- 创建索引
CREATE INDEX idx_model_billing_created ON model_billing_records(created_at);
CREATE INDEX idx_model_billing_tenant_id ON model_billing_records(tenant_id);
CREATE INDEX idx_model_billing_channel_id ON model_billing_records(channel_id);
CREATE INDEX idx_model_billing_model ON model_billing_records(model_name);
CREATE INDEX idx_model_billing_team ON model_billing_records(team_id);

-- 添加注释
COMMENT ON TABLE model_billing_records IS '模型调用计费记录,由 LiteLLM Callback 推送';
COMMENT ON COLUMN model_billing_records.litellm_call_id IS 'LiteLLM 调用唯一标识';
COMMENT ON COLUMN model_billing_records.eu_consumed IS '消耗的执行单元,根据 Token 和定价计算';

3.2 Step 2: 数据模型定义

文件: services/mcp-server/models.py(添加新模型)

class ModelBillingRecord(BaseModel, Base):
    """模型调用计费记录
    
    存储 LiteLLM Callback 推送的 Token 用量数据。
    每次模型调用后,LiteLLM 会异步回调 mcp-server,
    mcp-server 解析数据并写入此表。
    """
    __tablename__ = "model_billing_records"
    
    # 关联信息
    tenant_id = Column(GUID(), ForeignKey("users.id"))
    channel_id = Column(GUID(), ForeignKey("channels.id"))
    
    # LiteLLM 回调数据
    litellm_call_id = Column(String(100), unique=True, nullable=False)
    api_key = Column(String(255))  # 脱敏存储
    team_id = Column(String(100))
    
    # 模型信息
    model_name = Column(String(100), nullable=False)
    model_id = Column(String(100))
    
    # Token 用量
    input_tokens = Column(Integer, default=0)
    output_tokens = Column(Integer, default=0)
    total_tokens = Column(Integer, default=0)
    
    # 费用计算
    input_cost = Column(sa.Numeric(12, 6), default=0)
    output_cost = Column(sa.Numeric(12, 6), default=0)
    total_cost = Column(sa.Numeric(12, 6), default=0)
    eu_consumed = Column(sa.Numeric(12, 4), default=0)
    
    # 调用信息
    request_id = Column(String(100))
    call_type = Column(String(50), default="completion")
    status = Column(String(20), default="success")
    
    # 时间信息
    start_time = Column(DateTime)
    end_time = Column(DateTime)
    response_time_ms = Column(Integer)
    
    # 原始数据
    raw_callback_data = Column(JSON)
    
    # 关联关系
    tenant = relationship("User")
    channel = relationship("Channel")
    
    __table_args__ = (
        Index("idx_model_billing_created", "created_at"),
        Index("idx_model_billing_tenant_id", tenant_id),
        Index("idx_model_billing_channel_id", channel_id),
        Index("idx_model_billing_model", model_name),
        Index("idx_model_billing_team", team_id),
    )

3.3 Step 3: Webhook 路由实现

文件: services/mcp-server/app/routes/billing_webhook.py(新建)

"""
LiteLLM Callback Webhook 路由

接收 LiteLLM 的实时回调数据,记录模型调用的 Token 用量和费用。

LiteLLM Callback 数据格式参考:
https://docs.litellm.ai/docs/proxy/logging#custom-callback
"""

import logging
from datetime import datetime
from typing import Optional, Dict, Any, List
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request, status, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from pydantic import BaseModel, Field

from database import get_db
from models import ModelBillingRecord, User, Channel, TenantModelKey, ModelPricing
from config import settings

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v1/billing", tags=["计费 Webhook"])


# ==================== Pydantic Schemas ====================

class LiteLLMUsage(BaseModel):
    """LiteLLM Token 用量"""
    prompt_tokens: int = Field(0, alias="prompt_tokens")
    completion_tokens: int = Field(0, alias="completion_tokens")
    total_tokens: int = Field(0, alias="total_tokens")
    
    class Config:
        populate_by_name = True


class LiteLLMCallbackData(BaseModel):
    """LiteLLM Callback 数据结构
    
    参考: https://docs.litellm.ai/docs/proxy/logging
    """
    # 调用标识
    call_id: str = Field(..., alias="id")
    call_type: str = Field("completion", alias="call_type")  # completion, embedding
    
    # 模型信息
    model: str
    model_id: Optional[str] = None
    
    # API Key 信息
    api_key: Optional[str] = None
    team_id: Optional[str] = None
    
    # Token 用量
    usage: Optional[LiteLLMUsage] = None
    
    # 费用(LiteLLM 计算)
    response_cost: Optional[float] = None
    
    # 时间信息
    startTime: Optional[str] = None  # ISO 格式
    endTime: Optional[str] = None
    response_time: Optional[float] = None  # 秒
    
    # 状态
    status: str = "success"  # success, failure
    
    # 元数据(包含 tenant_id, channel_id 等)
    metadata: Optional[Dict[str, Any]] = None
    
    # 原始请求/响应(可选)
    messages: Optional[List[Dict]] = None
    response: Optional[Dict] = None
    
    class Config:
        populate_by_name = True


class CallbackResponse(BaseModel):
    """Callback 响应"""
    success: bool
    message: str
    record_id: Optional[str] = None


# ==================== 辅助函数 ====================

def mask_api_key(key: Optional[str]) -> Optional[str]:
    """脱敏 API Key"""
    if not key:
        return None
    if len(key) <= 10:
        return "***"
    return f"{key[:8]}...{key[-4:]}"


async def get_tenant_from_metadata(
    metadata: Optional[Dict],
    api_key: Optional[str],
    team_id: Optional[str],
    db: AsyncSession
) -> tuple[Optional[str], Optional[str]]:
    """从 metadata 或 API Key 解析租户和渠道 ID
    
    解析优先级:
    1. metadata 中的 tenant_id 和 channel_id
    2. 通过 api_key 查询 TenantModelKey 表
    3. 通过 team_id 查询 Channel 表
    """
    tenant_id = None
    channel_id = None
    
    # 1. 从 metadata 解析
    if metadata:
        tenant_id = metadata.get("tenant_id")
        channel_id = metadata.get("channel_id")
        if tenant_id and channel_id:
            return tenant_id, channel_id
    
    # 2. 通过 api_key 查询
    if api_key and not tenant_id:
        # 注意:api_key 可能是加密的,需要匹配
        result = await db.execute(
            select(TenantModelKey).where(
                TenantModelKey.litellm_key_id == api_key
            )
        )
        tenant_key = result.scalar_one_or_none()
        if tenant_key:
            tenant_id = str(tenant_key.tenant_id)
            channel_id = str(tenant_key.channel_id) if tenant_key.channel_id else None
    
    # 3. 通过 team_id 查询渠道
    if team_id and not channel_id:
        result = await db.execute(
            select(Channel).where(Channel.litellm_team_id == team_id)
        )
        channel = result.scalar_one_or_none()
        if channel:
            channel_id = str(channel.id)
    
    return tenant_id, channel_id


async def calculate_eu_from_tokens(
    model_name: str,
    input_tokens: int,
    output_tokens: int,
    response_cost: Optional[float],
    db: AsyncSession
) -> Decimal:
    """根据 Token 用量计算 EU 消耗
    
    计算逻辑:
    1. 如果 LiteLLM 返回了 response_cost,直接使用
    2. 否则查询 ModelPricing 表计算
    3. 最后按 EU 转换率转换
    
    EU 转换率:1 EU = $0.01(可配置)
    """
    EU_PER_DOLLAR = Decimal("100")  # 1 美元 = 100 EU
    
    if response_cost is not None and response_cost > 0:
        # 直接使用 LiteLLM 计算的成本
        return Decimal(str(response_cost)) * EU_PER_DOLLAR
    
    # 查询模型定价
    result = await db.execute(
        select(ModelPricing).where(
            ModelPricing.model_name == model_name,
            ModelPricing.is_active == True
        )
    )
    pricing = result.scalar_one_or_none()
    
    if pricing:
        # 计算成本(每 1K tokens)
        input_cost = (Decimal(input_tokens) / 1000) * pricing.input_price_per_1k
        output_cost = (Decimal(output_tokens) / 1000) * pricing.output_price_per_1k
        total_cost = input_cost + output_cost
        
        # 如果有 EU 转换率配置,使用配置值
        if pricing.eu_per_1k_tokens:
            total_tokens = input_tokens + output_tokens
            return (Decimal(total_tokens) / 1000) * pricing.eu_per_1k_tokens
        
        return total_cost * EU_PER_DOLLAR
    
    # 默认:每 1K tokens = 0.1 EU
    total_tokens = input_tokens + output_tokens
    return Decimal(total_tokens) / 1000 * Decimal("0.1")


async def update_tenant_eu_balance(
    tenant_id: str,
    eu_consumed: Decimal,
    db: AsyncSession
):
    """更新租户 EU 余额
    
    扣减 eu_balance,增加 total_eu_consumed
    """
    await db.execute(
        update(User).where(User.id == tenant_id).values(
            eu_balance=User.eu_balance - eu_consumed,
            total_eu_consumed=User.total_eu_consumed + eu_consumed
        )
    )


# ==================== Webhook 端点 ====================

@router.post("/litellm-callback", response_model=CallbackResponse)
async def litellm_callback(
    request: Request,
    db: AsyncSession = Depends(get_db),
    x_litellm_signature: Optional[str] = Header(None, alias="X-LiteLLM-Signature")
):
    """LiteLLM Callback Webhook 端点
    
    接收 LiteLLM 的实时回调数据,记录模型调用的 Token 用量。
    
    **安全说明**:
    - 建议配置 X-LiteLLM-Signature 进行签名验证
    - 生产环境应限制来源 IP
    
    **回调数据示例**:
    ```json
    {
        "id": "chatcmpl-xxx",
        "call_type": "completion",
        "model": "azure/gpt-4",
        "api_key": "sk-xxx",
        "team_id": "team-xxx",
        "usage": {
            "prompt_tokens": 100,
            "completion_tokens": 50,
            "total_tokens": 150
        },
        "response_cost": 0.0045,
        "startTime": "2026-01-08T10:00:00Z",
        "endTime": "2026-01-08T10:00:02Z",
        "status": "success",
        "metadata": {
            "tenant_id": "uuid",
            "channel_id": "uuid"
        }
    }
    ```
    """
    try:
        # 解析请求体
        body = await request.json()
        logger.info(f"收到 LiteLLM Callback: call_id={body.get('id')}")
        
        # 验证签名(可选,生产环境建议启用)
        # if settings.litellm_callback_secret:
        #     if not verify_signature(x_litellm_signature, body):
        #         raise HTTPException(status_code=401, detail="签名验证失败")
        
        # 解析回调数据
        callback_data = LiteLLMCallbackData(**body)
        
        # 检查是否已处理(幂等性)
        existing = await db.execute(
            select(ModelBillingRecord).where(
                ModelBillingRecord.litellm_call_id == callback_data.call_id
            )
        )
        if existing.scalar_one_or_none():
            logger.info(f"Callback 已处理,跳过: {callback_data.call_id}")
            return CallbackResponse(
                success=True,
                message="Already processed"
            )
        
        # 解析租户和渠道
        tenant_id, channel_id = await get_tenant_from_metadata(
            callback_data.metadata,
            callback_data.api_key,
            callback_data.team_id,
            db
        )
        
        # 提取 Token 用量
        input_tokens = callback_data.usage.prompt_tokens if callback_data.usage else 0
        output_tokens = callback_data.usage.completion_tokens if callback_data.usage else 0
        total_tokens = callback_data.usage.total_tokens if callback_data.usage else 0
        
        # 计算 EU 消耗
        eu_consumed = await calculate_eu_from_tokens(
            callback_data.model,
            input_tokens,
            output_tokens,
            callback_data.response_cost,
            db
        )
        
        # 解析时间
        start_time = None
        end_time = None
        response_time_ms = None
        
        if callback_data.startTime:
            try:
                start_time = datetime.fromisoformat(callback_data.startTime.replace("Z", "+00:00"))
            except Exception:
                pass
        
        if callback_data.endTime:
            try:
                end_time = datetime.fromisoformat(callback_data.endTime.replace("Z", "+00:00"))
            except Exception:
                pass
        
        if callback_data.response_time:
            response_time_ms = int(callback_data.response_time * 1000)
        
        # 计算成本
        input_cost = Decimal("0")
        output_cost = Decimal("0")
        total_cost = Decimal(str(callback_data.response_cost or 0))
        
        # 创建计费记录
        record = ModelBillingRecord(
            tenant_id=tenant_id,
            channel_id=channel_id,
            litellm_call_id=callback_data.call_id,
            api_key=mask_api_key(callback_data.api_key),
            team_id=callback_data.team_id,
            model_name=callback_data.model,
            model_id=callback_data.model_id,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            total_tokens=total_tokens,
            input_cost=input_cost,
            output_cost=output_cost,
            total_cost=total_cost,
            eu_consumed=eu_consumed,
            request_id=callback_data.call_id,
            call_type=callback_data.call_type,
            status=callback_data.status,
            start_time=start_time,
            end_time=end_time,
            response_time_ms=response_time_ms,
            raw_callback_data=body
        )
        
        db.add(record)
        
        # 更新租户 EU 余额
        if tenant_id:
            await update_tenant_eu_balance(tenant_id, eu_consumed, db)
        
        await db.commit()
        
        logger.info(
            f"LiteLLM Callback 处理成功: call_id={callback_data.call_id}, "
            f"model={callback_data.model}, tokens={total_tokens}, eu={eu_consumed}"
        )
        
        return CallbackResponse(
            success=True,
            message="Callback processed",
            record_id=str(record.id)
        )
        
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"LiteLLM Callback 处理失败: {e}", exc_info=True)
        # 返回 200 避免 LiteLLM 重试(可根据需求调整)
        return CallbackResponse(
            success=False,
            message=f"Processing error: {str(e)}"
        )


@router.get("/litellm-callback/health")
async def callback_health():
    """Callback 端点健康检查"""
    return {"status": "ok", "endpoint": "/api/v1/billing/litellm-callback"}

3.4 Step 4: 注册路由

文件: services/mcp-server/app/routes/__init__.py(修改)

# 在现有导入后添加
from app.routes.billing_webhook import router as billing_webhook_router

# 在 include_router 部分添加
app.include_router(billing_webhook_router)

3.5 Step 5: LiteLLM 配置

文件: services/model-gateway/config/litellm.yaml(修改)

# 基础设置
general_settings:
  master_key: "os.environ/LITELLM_MASTER_KEY"
  database_url: "os.environ/DATABASE_URL"
  
  # ... 其他配置保持不变 ...
  
  # 回调配置 - 添加 mcp-server webhook
  success_callback: ["langfuse", "webhook"]
  failure_callback: ["langfuse", "webhook"]

# 回调配置
callbacks:
  # 成功回调
  success_callback:
    - callback_name: "langfuse"
      callback_type: "success"
      callback_vars:
        langfuse_public_key: "os.environ/LANGFUSE_PUBLIC_KEY"
        langfuse_secret_key: "os.environ/LANGFUSE_SECRET_KEY"
        langfuse_host: "os.environ/LANGFUSE_HOST"
    
    # 新增:mcp-server webhook 回调
    - callback_name: "webhook"
      callback_type: "success"
      callback_vars:
        webhook_url: "os.environ/MCP_SERVER_CALLBACK_URL"
        # 可选:签名密钥
        # webhook_secret: "os.environ/LITELLM_CALLBACK_SECRET"
        
  # 失败回调  
  failure_callback:
    - callback_name: "langfuse"
      callback_type: "failure"
      callback_vars:
        langfuse_public_key: "os.environ/LANGFUSE_PUBLIC_KEY"
        langfuse_secret_key: "os.environ/LANGFUSE_SECRET_KEY"
        langfuse_host: "os.environ/LANGFUSE_HOST"
    
    # 新增:mcp-server webhook 回调
    - callback_name: "webhook"
      callback_type: "failure"
      callback_vars:
        webhook_url: "os.environ/MCP_SERVER_CALLBACK_URL"

3.6 Step 6: 环境变量配置

文件: .env 或 docker-compose.yml

# LiteLLM Gateway 配置
MCP_SERVER_CALLBACK_URL=http://mcp-server:8000/api/v1/billing/litellm-callback

# 可选:Callback 签名密钥(用于验证请求来源)
LITELLM_CALLBACK_SECRET=your-secret-key

3.7 Step 7: 修改 Dashboard 查询

文件: services/mcp-server/monitoring.py(修改 EU 消耗查询)

async def get_eu_consumption_stats(
    self,
    tenant_id: Optional[str] = None,
    channel_id: Optional[str] = None,
    start_date: Optional[datetime] = None,
    end_date: Optional[datetime] = None,
    db: AsyncSession = None
) -> Dict[str, Any]:
    """获取 EU 消耗统计
    
    合并查询:
    1. agent_billing_records - Agent 运行时计费
    2. model_billing_records - 模型 Token 计费
    """
    from models import AgentBillingRecord, ModelBillingRecord
    
    # Agent 运行时 EU 消耗
    agent_query = select(
        func.coalesce(func.sum(AgentBillingRecord.cost), 0).label("agent_eu")
    )
    if tenant_id:
        agent_query = agent_query.where(AgentBillingRecord.user_id == tenant_id)
    if channel_id:
        agent_query = agent_query.where(AgentBillingRecord.channel_id == channel_id)
    if start_date:
        agent_query = agent_query.where(AgentBillingRecord.created_at >= start_date)
    if end_date:
        agent_query = agent_query.where(AgentBillingRecord.created_at <= end_date)
    
    agent_result = await db.execute(agent_query)
    agent_eu = agent_result.scalar() or Decimal("0")
    
    # 模型 Token EU 消耗
    model_query = select(
        func.coalesce(func.sum(ModelBillingRecord.eu_consumed), 0).label("model_eu")
    )
    if tenant_id:
        model_query = model_query.where(ModelBillingRecord.tenant_id == tenant_id)
    if channel_id:
        model_query = model_query.where(ModelBillingRecord.channel_id == channel_id)
    if start_date:
        model_query = model_query.where(ModelBillingRecord.created_at >= start_date)
    if end_date:
        model_query = model_query.where(ModelBillingRecord.created_at <= end_date)
    
    model_result = await db.execute(model_query)
    model_eu = model_result.scalar() or Decimal("0")
    
    total_eu = agent_eu + model_eu
    
    return {
        "totalEuConsumed": float(total_eu),
        "agentEuConsumed": float(agent_eu),
        "modelEuConsumed": float(model_eu),
        "breakdown": {
            "agentRuntime": float(agent_eu),
            "modelTokens": float(model_eu)
        }
    }

4. 测试验证

4.1 单元测试

文件: services/mcp-server/tests/test_billing_webhook.py

import pytest
from httpx import AsyncClient
from datetime import datetime

@pytest.mark.asyncio
async def test_litellm_callback_success(client: AsyncClient, db):
    """测试正常的 LiteLLM Callback"""
    callback_data = {
        "id": "chatcmpl-test-001",
        "call_type": "completion",
        "model": "azure/gpt-4",
        "api_key": "sk-test-key",
        "team_id": "team-test",
        "usage": {
            "prompt_tokens": 100,
            "completion_tokens": 50,
            "total_tokens": 150
        },
        "response_cost": 0.0045,
        "startTime": "2026-01-08T10:00:00Z",
        "endTime": "2026-01-08T10:00:02Z",
        "status": "success",
        "metadata": {
            "tenant_id": "test-tenant-uuid",
            "channel_id": "test-channel-uuid"
        }
    }
    
    response = await client.post(
        "/api/v1/billing/litellm-callback",
        json=callback_data
    )
    
    assert response.status_code == 200
    data = response.json()
    assert data["success"] == True
    assert data["record_id"] is not None


@pytest.mark.asyncio
async def test_litellm_callback_idempotent(client: AsyncClient, db):
    """测试 Callback 幂等性"""
    callback_data = {
        "id": "chatcmpl-test-002",
        "model": "azure/gpt-4",
        "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
    }
    
    # 第一次调用
    response1 = await client.post("/api/v1/billing/litellm-callback", json=callback_data)
    assert response1.status_code == 200
    
    # 第二次调用(相同 call_id)
    response2 = await client.post("/api/v1/billing/litellm-callback", json=callback_data)
    assert response2.status_code == 200
    assert response2.json()["message"] == "Already processed"

4.2 集成测试

# 1. 启动服务
docker-compose up -d mcp-server litellm-gateway

# 2. 模拟 LiteLLM Callback
curl -X POST http://localhost:8000/api/v1/billing/litellm-callback \
  -H "Content-Type: application/json" \
  -d '{
    "id": "test-call-001",
    "model": "azure/gpt-4",
    "usage": {
      "prompt_tokens": 100,
      "completion_tokens": 50,
      "total_tokens": 150
    },
    "response_cost": 0.0045,
    "status": "success"
  }'

# 3. 验证记录
psql -d taiji -c "SELECT * FROM model_billing_records ORDER BY created_at DESC LIMIT 5;"

# 4. 验证 Dashboard EU 消耗
curl http://localhost:8000/api/v1/monitoring/dashboard \
  -H "Authorization: Bearer $TOKEN"

4.3 端到端测试

# 1. 通过 LiteLLM 发起真实模型调用
curl -X POST http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-tenant-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "azure/gpt-4",
    "messages": [{"role": "user", "content": "Hello"}]
  }'

# 2. 等待 Callback(通常 1-2 秒)
sleep 2

# 3. 检查计费记录
psql -d taiji -c "SELECT model_name, input_tokens, output_tokens, eu_consumed FROM model_billing_records ORDER BY created_at DESC LIMIT 1;"

5. 部署检查清单

5.1 部署前检查

  • 数据库迁移已执行
  • model_billing_records 表已创建
  • billing_webhook.py 路由已注册
  • 环境变量 MCP_SERVER_CALLBACK_URL 已配置
  • LiteLLM 配置已更新(添加 webhook callback)
  • 网络连通性:LiteLLM → mcp-server

5.2 部署后验证

  • Callback 端点健康检查通过
  • 模拟 Callback 请求成功
  • 真实模型调用触发 Callback
  • 计费记录正确写入数据库
  • Dashboard EU 消耗显示正确

6. 监控和告警

6.1 关键指标

指标 说明 告警阈值
callback_requests_total Callback 请求总数 -
callback_errors_total Callback 错误数 > 10/min
callback_latency_ms Callback 处理延迟 > 500ms
model_billing_records_count 计费记录数 -

6.2 日志监控

# 监控 Callback 日志
tail -f /var/log/mcp-server/app.log | grep "LiteLLM Callback"

# 监控错误
tail -f /var/log/mcp-server/app.log | grep -E "Callback.*失败|error"

7. 故障排查

7.1 常见问题

问题 可能原因 解决方案
Callback 未收到 LiteLLM 配置错误 检查 success_callback 配置
Callback 返回 500 数据库连接失败 检查 DB 连接
EU 消耗为 0 租户 ID 解析失败 检查 metadata 配置
重复记录 幂等性检查失败 检查 litellm_call_id 唯一约束

7.2 调试命令

# 检查 LiteLLM Callback 配置
curl http://localhost:4000/config | jq '.callbacks'

# 检查 mcp-server 路由
curl http://localhost:8000/openapi.json | jq '.paths | keys | map(select(contains("billing")))'

# 检查数据库记录
psql -d taiji -c "SELECT COUNT(*) FROM model_billing_records WHERE created_at > NOW() - INTERVAL '1 hour';"

8. 相关文件索引

文件 说明
services/mcp-server/migrations/012_add_model_billing_records.sql 数据库迁移(待创建)
services/mcp-server/app/routes/billing_webhook.py Webhook 路由(待创建)
services/mcp-server/models.py 数据模型(需添加 ModelBillingRecord)
services/model-gateway/config/litellm.yaml LiteLLM 配置(需修改)
services/mcp-server/monitoring.py Dashboard 查询(需修改)

9. 后续优化

9.1 短期优化

  1. 签名验证:启用 X-LiteLLM-Signature 验证请求来源
  2. 批量处理:支持批量 Callback 减少请求数
  3. 异步队列:使用 NATS 异步处理 Callback,提高吞吐量

9.2 长期优化

  1. 实时仪表板:WebSocket 推送 EU 消耗变化
  2. 预算告警:EU 余额不足时实时告警
  3. 成本分析:按模型、时间段分析成本趋势
  4. 数据归档:定期归档历史计费记录

10. 总结

10.1 实施优先级

高优先级(必须完成):
├── Step 1: 数据库迁移
├── Step 2: 数据模型
├── Step 3: Webhook 路由
└── Step 5: LiteLLM 配置

中优先级(建议完成):
├── Step 6: Dashboard 查询修改
└── Step 7: 测试验证

低优先级(可选):
├── 签名验证
├── 批量处理
└── 异步队列

10.2 预计工作量

步骤 工作内容 复杂度
Step 1 数据库迁移 低
Step 2 数据模型 低
Step 3 Webhook 路由 中
Step 4 注册路由 低
Step 5 LiteLLM 配置 低
Step 6 Dashboard 修改 中
Step 7 测试验证 中

10.3 风险评估

风险 影响 缓解措施
LiteLLM 版本兼容性 中 测试环境验证
网络延迟 低 异步处理
数据丢失 高 幂等性设计 + 日志
性能瓶颈 中 批量处理 + 队列