1 line
293 KiB
JSON
1 line
293 KiB
JSON
{"repo": "bukely/rag-api", "files": {"README.md": {"f7e2565a6faa09c79be1148c9087ba34841ae066": {"t": 1785674112.3469043, "n": 34709, "h": "1e1ca73e9cec83f9", "c": "<div align=\"center\">\n\n# 🚀 RAG API\n\n**Multi-tenant Multimodal Document Intelligent Retrieval System**\n\nEnterprise-grade RAG service built on RAG-Anything and LightRAG\n\n[](https://github.com/BukeLy/rag-api/actions/workflows/ci-basic.yml)\n[](https://www.python.org/)\n[](https://fastapi.tiangolo.com/)\n[](https://github.com/HKUDS/LightRAG)\n[](https://www.docker.com/)\n[](LICENSE)\n\n[English](README.md) | [简体中文](README.zh-CN.md)\n\n[Features](#-features) • [Quick Start](#-quick-start) • [Architecture](#-architecture) • [API Documentation](#-api-documentation) • [Deployment](#-deployment)\n\n</div>\n\n---\n\n## 📖 Introduction\n\nRAG API is an enterprise-grade Retrieval-Augmented Generation (RAG) service that combines the powerful document parsing capabilities of **RAG-Anything** with the efficient knowledge graph retrieval technology of **LightRAG**, providing intelligent Q&A capabilities for your documents.\n\n### 🎯 Key Highlights\n\n- 🏢 **Multi-tenant Isolation** - Complete tenant data isolation for enterprise multi-tenant scenarios\n- 🎨 **Multimodal Parsing** - Support for PDF, Word, images and more, with full OCR, tables, and formulas coverage\n- ⚡ **High-performance Retrieval** - Knowledge graph-based hybrid retrieval with 6-15 second query response\n- 🔄 **Flexible Deployment** - Support for production and development modes with one-click switching\n- 📦 **Ready to Use** - One-click Docker deployment, service starts in 3 minutes\n- 🎛️ **Multiple Parsing Engines** - DeepSeek-OCR (Remote API) + MinerU (Local/Remote API) + Docling (Fast)\n- 🎨 **RAG-Anything VLM Enhancement** - Three modes (off/selective/full) for deep chart understanding\n- 💾 **Task Persistence** - Redis storage support, tasks recoverable after container restart/instance rebuild\n\n---\n\n## ✨ Features\n\n<table>\n<tr>\n<td width=\"50%\">\n\n### 📄 Document Processing\n\n- ✅ **Multiple Format Support**\n - PDF, Word, Excel, PPT\n - PNG, JPG, WebP images\n - TXT, Markdown text\n \n- ✅ **Intelligent Parsing**\n - Plain text (.txt, .md) → Direct insertion (ultra-fast ~1s, skip parser)\n - OCR text recognition\n - Structured table extraction\n - Mathematical formula recognition\n - Layout analysis\n\n- ✅ **RAG-Anything VLM Enhancement** 🆕\n - `off` - Markdown only (fastest)\n - `selective` - Selective processing of important charts\n - `full` - Complete context enhancement processing\n - Smart filtering: with titles, large size, first page content\n - ⚠️ **Only supports remote MinerU mode, local mode uses RAG-Anything native methods**\n\n- ✅ **Batch Processing**\n - Up to 100 files per batch\n - Async task queue\n - Real-time progress tracking\n\n</td>\n<td width=\"50%\">\n\n### 🔍 Intelligent Retrieval\n\n- ✅ **Multi-mode Query**\n - `naive` - Vector retrieval (fastest)\n - `local` - Local graph\n - `global` - Global graph\n - `hybrid` - Hybrid retrieval\n - `mix` - Full retrieval (most accurate)\n\n- ✅ **Knowledge Graph**\n - Automatic entity extraction\n - Relationship reasoning\n - Semantic understanding\n - Context enhancement\n\n- ✅ **External Storage**\n - DragonflyDB (KV storage + task storage)\n - Qdrant (vector storage)\n - Memgraph (graph database)\n - Task persistence (Redis mode)\n\n</td>\n</tr>\n</table>\n\n---\n\n## 🏗️ Architecture\n\n### System Architecture Diagram\n\n```mermaid\ngraph TB\n subgraph \"Client Layer\"\n Client[Client Application]\n WebUI[Web Interface]\n end\n \n subgraph \"API Gateway Layer\"\n FastAPI[FastAPI Service]\n Auth[Tenant Authentication]\n end\n \n subgraph \"Business Logic Layer\"\n TenantMgr[Tenant Manager]\n TaskQueue[Task Queue]\n \n subgraph \"Document Processing\"\n DeepSeekOCR[DeepSeek-OCR<br/>Fast OCR 80% cases]\n MinerU[MinerU Parser<br/>Complex multimodal]\n Docling[Docling Parser<br/>Fast lightweight]\n FileRouter[Smart Router<br/>Complexity scoring]\n end\n \n subgraph \"RAG Engine\"\n LightRAG[LightRAG Instance Pool<br/>LRU Cache 50]\n KG[Knowledge Graph Engine]\n Vector[Vector Retrieval Engine]\n end\n end\n \n subgraph \"Storage Layer\"\n DragonflyDB[(DragonflyDB<br/>KV Storage)]\n Qdrant[(Qdrant<br/>Vector Database)]\n Memgraph[(Memgraph<br/>Graph Database)]\n Local[(Local Files<br/>Temp Storage)]\n end\n \n subgraph \"External Services\"\n LLM[LLM<br/>Entity Extraction/Generation]\n Embedding[Embedding<br/>Vectorization]\n Rerank[Rerank<br/>Reranking]\n end\n \n Client --> FastAPI\n WebUI --> FastAPI\n FastAPI --> Auth\n Auth --> TenantMgr\n TenantMgr --> TaskQueue\n TenantMgr --> LightRAG\n \n TaskQueue --> FileRouter\n FileRouter --> DeepSeekOCR\n FileRouter --> MinerU\n FileRouter --> Docling\n DeepSeekOCR --> LightRAG\n MinerU --> LightRAG\n Docling --> LightRAG\n \n LightRAG --> KG\n LightRAG --> Vector\n \n KG --> DragonflyDB\n KG --> Memgraph\n Vector --> Qdrant\n LightRAG --> Local\n \n LightRAG --> LLM\n LightRAG --> Embedding\n Vector --> Rerank\n \n style FastAPI fill:#00C7B7\n style LightRAG fill:#FF6B6B\n style DeepSeekOCR fill:#5DADE2\n style MinerU fill:#4ECDC4\n style Docling fill:#95E1D3\n style TenantMgr fill:#F38181\n```\n\n### Multi-tenant Architecture\n\n```mermaid\ngraph TB\n subgraph \"Tenant A\"\n A_Config[Tenant A Config<br/>Independent API Key]\n A_Instance[LightRAG Instance A<br/>Dedicated LLM/Embedding]\n A_Data[(Tenant A Data<br/>Fully Isolated)]\n A_Config --> A_Instance\n A_Instance --> A_Data\n end\n\n subgraph \"Tenant B\"\n B_Config[Tenant B Config<br/>Independent API Key]\n B_Instance[LightRAG Instance B<br/>Dedicated LLM/Embedding]\n B_Data[(Tenant B Data<br/>Fully Isolated)]\n B_Config --> B_Instance\n B_Instance --> B_Data\n end\n\n subgraph \"Tenant C\"\n C_Config[Using Global Config]\n C_Instance[LightRAG Instance C<br/>Shared LLM/Embedding]\n C_Data[(Tenant C Data<br/>Fully Isolated)]\n C_Config --> C_Instance\n C_Instance --> C_Data\n end\n\n Pool[Instance Pool Manager<br/>LRU Cache + Config Isolation]\n Global[Global Config<br/>Default API Key]\n\n Pool --> A_Instance\n Pool --> B_Instance\n Pool --> C_Instance\n\n C_Config -.fallback.-> Global\n\n style Pool fill:#F38181\n style Global fill:#95E1D3\n style A_Config fill:#FFD93D\n style B_Config fill:#FFD93D\n style C_Config fill:#E8E8E8\n```\n\n### Core Technology Stack\n\n<table>\n<tr>\n<td width=\"33%\">\n\n**🔧 Frameworks & Runtime**\n- FastAPI 0.115+\n- Python 3.11+\n- Uvicorn\n- Docker & Docker Compose\n\n</td>\n<td width=\"33%\">\n\n**🧠 AI & RAG**\n- LightRAG 1.4.9.4\n- RAG-Anything\n- MinerU (PDF-Extract-Kit)\n- Docling\n\n</td>\n<td width=\"33%\">\n\n**💾 Storage & Database**\n- DragonflyDB(Redis compatible)\n- Qdrant(Vector Database)\n- Memgraph(Graph Database)\n- Local filesystem\n\n</td>\n</tr>\n</table>\n\n---\n\n## 🚀 Quick Start\n\n### Option 1: One-click Deployment (Recommended)\n\nSuitable for production and testing environments:\n\n```bash\n# 1. Clone the project\ngit clone https://github.com/BukeLy/rag-api.git\ncd rag-api\n\n# 2. Configure environment variables\ncp env.example .env\nnano .env # Fill in your API keys\n\n# 3. Run deployment script\nchmod +x deploy.sh\n./deploy.sh\n\n# Select deployment mode:\n# 1) Production Mode - Standard container deployment\n# 2) Development Mode - Code hot-reload\n\n# 4. Verify service\ncurl http://localhost:8000/\n```\n\n**Access Swagger Documentation:** http://localhost:8000/docs\n\n### Option 2: Docker Compose\n\n#### Production Mode\n\n```bash\n# Configure environment variables\ncp env.example .env\nnano .env\n\n# Start services\ndocker compose -f docker-compose.yml up -d\n\n# View logs\ndocker compose -f docker-compose.yml logs -f\n```\n\n#### Development Mode (Code Hot-reload)\n\n```bash\n# Start development environment\ndocker compose -f docker-compose.dev.yml up -d\n\n# Or use quick script\n./scripts/dev.sh\n\n# Code changes will auto-reload without restart\n```\n\n### Option 3: Local Development\n\n```bash\n# Install uv (Python package manager)\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# Install dependencies\nuv sync\n\n# Configure environment variables\ncp env.example .env\nnano .env\n\n# Start services\nuv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload\n```\n\n### Environment Variable Configuration\n\nMinimum configuration (required):\n\n```bash\n# LLM Configuration (Function-oriented naming)\nLLM_API_KEY=your_llm_api_key\nLLM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3\nLLM_MODEL=ep-xxx-xxx\n# LLM_REQUESTS_PER_MINUTE=800 # Rate limit (optional)\n# LLM_TOKENS_PER_MINUTE=40000 # Rate limit (optional)\n# LLM_MAX_ASYNC=8 # [Optional, expert mode] Manual concurrency control\n# # Auto-calculated when unset: min(RPM, TPM/3500) = 11\n\n# Embedding Configuration (Function-oriented naming)\nEMBEDDING_API_KEY=your_embedding_api_key\nEMBEDDING_BASE_URL=https://api.siliconflow.cn/v1\nEMBEDDING_MODEL=Qwen/Qwen3-Embedding-0.6B\nEMBEDDING_DIM=1024\n# EMBEDDING_MAX_ASYNC=32 # [Optional, expert mode] Auto-calculated when unset: 800\n\n# MinerU Mode (Remote recommended)\nMINERU_MODE=remote\nMINERU_API_TOKEN=your_token\nMINERU_HTTP_TIMEOUT=60 # MinerU download timeout (seconds, default 60)\nFILE_SERVICE_BASE_URL=http://your-ip:8000\n\n# VLM Chart Enhancement Configuration 🆕\n# ⚠️ Note: Only effective in MINERU_MODE=remote\nRAG_VLM_MODE=off # off / selective / full\nRAG_IMPORTANCE_THRESHOLD=0.5 # Importance threshold (selective mode)\nRAG_CONTEXT_WINDOW=2 # Context window (full mode)\nRAG_CONTEXT_MODE=page # page / chunk\nRAG_MAX_CONTEXT_TOKENS=3000 # Max context tokens\n\n# Task Storage Configuration 🆕\nTASK_STORE_STORAGE=redis # memory / redis (production recommends redis)\n\n# Document Insert Verification Configuration 🆕\nDOC_INSERT_VERIFICATION_TIMEOUT=300 # Verification timeout (seconds, default 5 minutes)\nDOC_INSERT_VERIFICATION_POLL_INTERVAL=0.5 # Poll interval (seconds, default 500ms)\n\n# Model Call Timeout Configuration 🆕\nMODEL_CALL_TIMEOUT=90 # Model call max timeout (seconds, default 90)\n```\n\n**⚡ Auto Concurrency Calculation**:\n- **LLM**: When `LLM_MAX_ASYNC` is unset, auto-calculated as `min(RPM, TPM/3500)` ≈ 11\n- **Embedding**: When `EMBEDDING_MAX_ASYNC` is unset, auto-calculated as `min(RPM, TPM/500)` ≈ 800\n- **Rerank**: When `RERANK_MAX_ASYNC` is unset, auto-calculated as `min(RPM, TPM/500)` ≈ 800\n\n**✅ Recommended**: Don't set `*_MAX_ASYNC`, let the system auto-calculate to completely avoid 429 errors\n\nSee `env.example` for complete configuration.\n\n---\n\n## 📚 API Documentation\n\n### Core Endpoints\n\n#### 1️⃣ Upload Document\n\n```bash\n# Single file upload (default mode)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc1\" \\\n -F \"file=@document.pdf\" \\\n -F \"parser=auto\"\n\n# VLM chart enhancement mode 🆕\n# off: Markdown only (fastest, default)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc2&vlm_mode=off\" \\\n -F \"file=@document.pdf\"\n\n# selective: Selective processing of important charts (balance performance and quality)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc3&vlm_mode=selective\" \\\n -F \"file=@document.pdf\"\n\n# full: Complete RAG-Anything processing (highest quality, context enhancement enabled)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc4&vlm_mode=full\" \\\n -F \"file=@document.pdf\"\n\n# Response\n{\n \"task_id\": \"task-xxx-xxx\",\n \"doc_id\": \"doc1\",\n \"filename\": \"document.pdf\",\n \"vlm_mode\": \"off\",\n \"status\": \"pending\"\n}\n```\n\n#### 2️⃣ Batch Upload\n\n```bash\ncurl -X POST \"http://localhost:8000/batch?tenant_id=your_tenant\" \\\n -F \"files=@doc1.pdf\" \\\n -F \"files=@doc2.docx\" \\\n -F \"files=@image.png\"\n\n# Response\n{\n \"batch_id\": \"batch-xxx-xxx\",\n \"total_files\": 3,\n \"accepted_files\": 3,\n \"tasks\": [...]\n}\n```\n\n#### 3️⃣ Intelligent Query (Query API v2.0)\n\n**New Advanced Features**:\n- ✨ **Conversation History**: Support for multi-turn conversation context\n- ✨ **Custom Prompts**: Customize response style\n- ✨ **Response Format Control**: paragraph/list/json\n- ✨ **Keyword Precision Retrieval**: hl_keywords/ll_keywords\n- ✨ **Streaming Output**: Real-time generation viewing\n\n```bash\n# Basic query\ncurl -X POST \"http://localhost:8000/query?tenant_id=your_tenant\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"What are the core viewpoints in the document?\",\n \"mode\": \"hybrid\"\n }'\n\n# Advanced query (multi-turn dialogue + custom prompt)\ncurl -X POST \"http://localhost:8000/query?tenant_id=your_tenant\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"Can you elaborate on the second point?\",\n \"mode\": \"hybrid\",\n \"conversation_history\": [\n {\"role\": \"user\", \"content\": \"What are the key points?\"},\n {\"role\": \"assistant\", \"content\": \"There are mainly three points...\"}\n ],\n \"user_prompt\": \"Please answer in professional academic language\",\n \"response_type\": \"list\"\n }'\n\n# Streaming query (SSE)\ncurl -N -X POST \"http://localhost:8000/query/stream?tenant_id=your_tenant\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"What are the core viewpoints in the document?\",\n \"mode\": \"hybrid\"\n }'\n\n# Response (real-time streaming output)\ndata: {\"chunk\": \"Based on\", \"done\": false}\ndata: {\"chunk\": \"document content\", \"done\": false}\ndata: {\"done\": true}\n```\n\n#### 4️⃣ Task Status Query\n\n```bash\ncurl \"http://localhost:8000/task/task-xxx-xxx?tenant_id=your_tenant\"\n\n# Response\n{\n \"task_id\": \"task-xxx-xxx\",\n \"status\": \"completed\",\n \"progress\": 100,\n \"result\": {...}\n}\n```\n\n#### 5️⃣ Tenant Management\n\n```bash\n# Get tenant statistics\ncurl \"http://localhost:8000/tenants/stats?tenant_id=your_tenant\"\n\n# Clear tenant cache\ncurl -X DELETE \"http://localhost:8000/tenants/cache?tenant_id=your_tenant\"\n\n# View instance pool status (admin)\ncurl \"http://localhost:8000/tenants/pool/stats\"\n```\n\n### VLM Mode Comparison 🆕\n\n| Mode | Speed | Quality | Resource Usage | Use Case |\n|------|------|------|----------|---------|\n| `off` | ⚡⚡⚡⚡⚡ | ⭐⭐⭐ | Very Low | Plain text documents, fast batch processing |\n| `selective` | ⚡⚡⚡⚡ | ⭐⭐⭐⭐ | Low | Documents with key charts (recommended) |\n| `full` | ⚡⚡ | ⭐⭐⭐⭐⭐ | High | Chart-intensive research reports, papers |\n\n**Processing Time Estimate** (20-page PDF example):\n- `off`: ~10 seconds(Markdown only)\n- `selective`: ~30 seconds(5-10 important charts)\n- `full`: ~120 seconds(complete context processing)\n\n### Query Mode Comparison\n\n| Mode | Speed | Accuracy | Use Case |\n|------|------|--------|---------|\n| `naive` | ⚡⚡⚡⚡⚡ | ⭐⭐⭐ | Simple Q&A, fast retrieval |\n| `local` | ⚡⚡⚡⚡ | ⭐⭐⭐⭐ | Local entity relationship queries |\n| `global` | ⚡⚡⚡ | ⭐⭐⭐⭐ | Global knowledge graph reasoning |\n| `hybrid` | ⚡⚡⚡ | ⭐⭐⭐⭐⭐ | Hybrid retrieval (recommended) |\n| `mix` | ⚡⚡ | ⭐⭐⭐⭐⭐ | Complex questions, deep analysis |\n\n### Query API v2.0 Advanced Parameters\n\n| Parameter | Type | Description | Example |\n|------|------|------|------|\n| `conversation_history` | List[Dict] | Multi-turn conversation context | `[{\"role\": \"user\", \"content\": \"...\"}]` |\n| `user_prompt` | str | Custom prompt | \"Please answer in professional academic language\" |\n| `response_type` | str | Response format | \"paragraph\", \"list\", \"json\" |\n| `hl_keywords` | List[str] | High priority keywords | `[\"artificial intelligence\", \"machine learning\"]` |\n| `ll_keywords` | List[str] | Low priority keywords | `[\"application\", \"case study\"]` |\n| `only_need_context` | bool | Return context only (debug) | `true` |\n| `max_entity_tokens` | int | Entity token limit | `6000` |\n\nComplete API documentation:http://localhost:8000/docs\n\n---\n\n## 🎯 Usage Examples\n\n### Python SDK\n\n```python\nimport requests\n\n# Configuration\nBASE_URL = \"http://localhost:8000\"\nTENANT_ID = \"your_tenant\"\n\n# Upload document\nwith open(\"document.pdf\", \"rb\") as f:\n response = requests.post(\n f\"{BASE_URL}/insert\",\n params={\"tenant_id\": TENANT_ID, \"doc_id\": \"doc1\"},\n files={\"file\": f}\n )\n task_id = response.json()[\"task_id\"]\n print(f\"Task ID: {task_id}\")\n\n# Query\nresponse = requests.post(\n f\"{BASE_URL}/query\",\n params={\"tenant_id\": TENANT_ID},\n json={\n \"query\": \"What is the main content of the document?\",\n \"mode\": \"hybrid\",\n \"top_k\": 10\n }\n)\nresult = response.json()\nprint(f\"Answer: {result['answer']}\")\n```\n\n### Complete cURL Example\n\n```bash\n# 1. Upload PDF document\nTASK_ID=$(curl -X POST \"http://localhost:8000/insert?tenant_id=demo&doc_id=report\" \\\n -F \"file=@report.pdf\" | jq -r '.task_id')\n\necho \"Task ID: $TASK_ID\"\n\n# 2. Wait for processing completion\nwhile true; do\n STATUS=$(curl -s \"http://localhost:8000/task/$TASK_ID?tenant_id=demo\" | jq -r '.status')\n echo \"Status: $STATUS\"\n if [ \"$STATUS\" = \"completed\" ] || [ \"$STATUS\" = \"failed\" ]; then\n break\n fi\n sleep 2\ndone\n\n# 3. Query document content\ncurl -X POST \"http://localhost:8000/query?tenant_id=demo\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"What are the main conclusions of this report?\",\n \"mode\": \"hybrid\"\n }' | jq '.answer'\n```\n\n---\n\n## 🛠️ Deployment\n\n### System Requirements\n\n**Minimum Configuration:**\n- CPU: 2 cores\n- RAM: 4GB\n- Disk: 40GB SSD\n- OS: Ubuntu 20.04+ / Debian 11+ / CentOS 8+\n\n**Recommended Configuration (Production):**\n- CPU: 4 cores\n- RAM: 8GB\n- Disk: 100GB SSD\n- OS: Ubuntu 22.04 LTS\n\n### Server Deployment\n\n#### Quick Deployment on Aliyun/Tencent Cloud\n\n```bash\n# SSH login to server\nssh root@your-server-ip\n\n# Clone project\ngit clone https://github.com/BukeLy/rag-api.git\ncd rag-api\n\n# Run one-click deployment script\nchmod +x deploy.sh\n./deploy.sh\n\n# The script will automatically:\n# 1. Install Docker and Docker Compose\n# 2. Configure environment variables\n# 3. Optimize system parameters\n# 4. Start services\n# 5. Verify health status\n```\n\n#### External Storage Configuration\n\nSupports DragonflyDB + Qdrant + Memgraph external storage (enabled by default):\n\n```bash\n# Configure in .env\nUSE_EXTERNAL_STORAGE=true\n\n# DragonflyDB configuration (KV Storage)\nKV_STORAGE=RedisKVStorage\nREDIS_URI=redis://dragonflydb:6379/0\n\n# Qdrant configuration (vector storage)\nVECTOR_STORAGE=QdrantVectorDBStorage\nQDRANT_URL=http://qdrant:6333\n\n# Memgraph configuration (graph storage)\nGRAPH_STORAGE=MemgraphStorage\nMEMGRAPH_URI=bolt://memgraph:7687\nMEMGRAPH_USERNAME=\nMEMGRAPH_PASSWORD=\n```\n\nSee [External Storage Deployment Documentation](docs/DEPLOYMENT_EXTERNAL_STORAGE.md)。\n\n### Docker Compose Configuration\n\nThe project provides two configuration files:\n\n| File | Purpose | Features |\n|------|------|------|\n| `docker-compose.yml` | Production mode | Code packaged in image, optimal performance |\n| `docker-compose.dev.yml` | Development mode | Code mounted externally, supports hot-reload |\n\nSelect configuration file:\n\n```bash\n# Production mode\ndocker compose -f docker-compose.yml up -d\n\n# Development mode\ndocker compose -f docker-compose.dev.yml up -d\n```\n\n### Performance Optimization\n\n#### Tuning Parameters\n\nConfigure in `.env`:\n\n```bash\n# ⚡ Concurrency Control (Recommended: use auto-calculation)\n# LLM_MAX_ASYNC=8 # [Expert mode] Manually specify LLM concurrency\n# # Auto-calculated when unset: min(RPM, TPM/3500) ≈ 11\n# EMBEDDING_MAX_ASYNC=32 # [Expert mode] Manually specify Embedding concurrency\n# # Auto-calculated when unset: min(RPM, TPM/500) ≈ 800\n# RERANK_MAX_ASYNC=16 # [Expert mode] Manually specify Rerank concurrency\n# # Auto-calculated when unset: min(RPM, TPM/500) ≈ 800\n\n# Retrieval count (affects query quality and speed)\nTOP_K=20 # Entity/relationship retrieval count\nCHUNK_TOP_K=10 # Text chunk retrieval count\n\n# Document processing concurrency\nDOCUMENT_PROCESSING_CONCURRENCY=10 # Remote mode can be set high, local mode set to 1\n```\n\n**🎯 Concurrency Configuration Recommendations**:\n- **Recommended**: Don't set `*_MAX_ASYNC`, let the system auto-calculate based on TPM/RPM\n- **Expert mode**: If manual control needed, can set `LLM_MAX_ASYNC` and other parameters\n- **Advantage**: Auto-calculation completely avoids 429 errors (TPM limit reached)\n\n#### Mode Selection\n\n- **MinerU Remote Mode (Recommended)**: High concurrency, resource-efficient\n- **MinerU Local Mode**: Requires GPU, high memory usage\n- **Docling Mode**: Fast and lightweight, suitable for simple documents\n\n---\n\n## 🏢 Multi-tenant Usage\n\n### Tenant Isolation\n\nEach tenant has:\n- ✅ Independent LightRAG instance\n- ✅ Isolated data storage space\n- ✅ Independent vector index\n- ✅ Dedicated knowledge graph\n- ✅ **Independent service configuration** (LLM, Embedding, Rerank, DeepSeek-OCR, MinerU)🆕\n\n### Tenant Configuration Management 🆕\n\nEach tenant can independently configure 5 services with hot-reload support:\n\n```bash\n# 1️⃣ Configure independent DeepSeek-OCR API key for Tenant A\ncurl -X PUT \"http://localhost:8000/tenants/tenant_a/config\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"ds_ocr_config\": {\n \"api_key\": \"sk-tenant-a-ds-ocr-key\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 90\n }\n }'\n\n# 2️⃣ Configure independent MinerU API token for Tenant B\ncurl -X PUT \"http://localhost:8000/tenants/tenant_b/config\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"mineru_config\": {\n \"api_token\": \"tenant-b-mineru-token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\"\n }\n }'\n\n# 3️⃣ Configure multiple services simultaneously (LLM + Embedding + DeepSeek-OCR)\ncurl -X PUT \"http://localhost:8000/tenants/tenant_c/config\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"llm_config\": {\n \"api_key\": \"sk-tenant-c-llm-key\",\n \"model\": \"gpt-4\"\n },\n \"embedding_config\": {\n \"api_key\": \"sk-tenant-c-embedding-key\",\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"dim\": 1024\n },\n \"ds_ocr_config\": {\n \"api_key\": \"sk-tenant-c-ds-ocr-key\"\n }\n }'\n\n# 4️⃣ Query tenant configuration (API key auto-masked)\ncurl \"http://localhost:8000/tenants/tenant_a/config\"\n\n# Response example\n{\n \"tenant_id\": \"tenant_a\",\n \"ds_ocr_config\": {\n \"api_key\": \"sk-***-key\", // Auto-masked\n \"timeout\": 90\n },\n \"merged_config\": {\n \"llm\": {...}, // Using Global Config\n \"embedding\": {...}, // Using Global Config\n \"rerank\": {...}, // Using Global Config\n \"ds_ocr\": {...}, // Using tenant config\n \"mineru\": {...} // Using Global Config\n }\n}\n\n# 5️⃣ Refresh config cache (config hot-reload)\ncurl -X POST \"http://localhost:8000/tenants/tenant_a/config/refresh\"\n\n# 6️⃣ Delete tenant config (restore to global config)\ncurl -X DELETE \"http://localhost:8000/tenants/tenant_a/config\"\n```\n\n**Supported Configuration Items**:\n\n| Service | Config Field | Description |\n|------|---------|------|\n| **LLM** | `llm_config` | Model, API key, base_url, etc. |\n| **Embedding** | `embedding_config` | Model, API key, dimension, etc. |\n| **Rerank** | `rerank_config` | Model, API key, etc. |\n| **DeepSeek-OCR** | `ds_ocr_config` | API key, timeout, mode, etc. |\n| **MinerU** | `mineru_config` | API token, version, timeout, etc. |\n\n**Configuration Priority**: Tenant config > Global config\n\n**Use Cases**:\n- 🔐 **Multi-tenant SaaS**: Each tenant uses their own API key\n- 💰 **Pay-per-use**: Track tenant usage through independent API keys\n- 🎯 **Differentiated Services**: Different tenants use different models (GPT-4 vs GPT-3.5)\n- 🧪 **A/B Testing**: Compare different models/parameters\n\n### Usage\n\nAll APIs require `tenant_id` parameter:\n\n```bash\n# Tenant A upload document\ncurl -X POST \"http://localhost:8000/insert?tenant_id=tenant_a&doc_id=doc1\" \\\n -F \"file=@doc.pdf\"\n\n# Tenant B upload document (fully isolated)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=tenant_b&doc_id=doc1\" \\\n -F \"file=@doc.pdf\"\n\n# Tenant A query (can only query own documents)\ncurl -X POST \"http://localhost:8000/query?tenant_id=tenant_a\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"query\": \"document content\", \"mode\": \"hybrid\"}'\n```\n\n### Instance Pool Management\n\n- **Capacity**: Cache up to 50 tenant instances\n- **Strategy**: LRU (Least Recently Used) automatic cleanup\n- **Config Isolation**: Each tenant can use independent LLM, Embedding, parser configuration\n\n---\n\n## 📊 Monitoring & Maintenance\n\n### Common Commands\n\n```bash\n# View service status\ndocker compose ps\n\n# View real-time logs\ndocker compose logs -f\n\n# Restart services\ndocker compose restart\n\n# Stop services\ndocker compose down\n\n# View resource usage\ndocker stats\n\n# Clean Docker resources\ndocker system prune -f\n```\n\n### Maintenance Scripts\n\n```bash\n# Monitor service health\n./scripts/monitor.sh\n\n# Backup data\n./scripts/backup.sh\n\n# Update services\n./scripts/update.sh\n\n# Performance testing\n./scripts/test_concurrent_perf.sh\n\n# Performance monitoring\n./scripts/monitor_performance.sh\n```\n\n### Health Checks\n\n```bash\n# Complete health check (recommended)\n./scripts/health_check.sh\n./scripts/health_check.sh --verbose # verbose output\n\n# API health check\ncurl http://localhost:8000/\n\n# Tenant statistics\ncurl \"http://localhost:8000/tenants/stats?tenant_id=your_tenant\"\n\n# Instance pool status\ncurl \"http://localhost:8000/tenants/pool/stats\"\n```\n\n---\n\n## 🗂️ Project Structure\n\n```\nrag-api/\n├── main.py # FastAPI application entry\n├── api/ # API route modules\n│ ├── __init__.py # Route aggregation\n│ ├── insert.py # Document upload (single/batch)\n│ ├── query.py # Intelligent query\n│ ├── task.py # Task status query\n│ ├── tenant.py # Tenant management\n│ ├── files.py # File service\n│ ├── models.py # Pydantic models\n│ └── task_store.py # Task storage\n├── src/ # Core business logic\n│ ├── rag.py # LightRAG lifecycle management\n│ ├── multi_tenant.py # Multi-tenant instance manager\n│ ├── tenant_deps.py # Tenant dependency injection\n│ ├── logger.py # Unified logging\n│ ├── metrics.py # Performance metrics\n│ ├── file_url_service.py # Temporary file service\n│ ├── mineru_client.py # MinerU client\n│ └── mineru_result_processor.py # Result processing\n├── docs/ # Documentation\n│ ├── ARCHITECTURE.md # Architecture design documentation\n│ ├── USAGE.md # Detailed usage guide\n│ ├── DEPLOY_MODES.md # Deployment mode description\n│ ├── PR_WORKFLOW.md # PR workflow\n│ └── ...\n├── scripts/ # Maintenance scripts\n│ ├── dev.sh # Development mode quick start\n│ ├── monitor.sh # Service monitoring\n│ ├── backup.sh # Data backup\n│ ├── update.sh # Service update\n│ └── ...\n├── deploy.sh # One-click deployment script\n├── docker-compose.yml # Production mode configuration\n├── docker-compose.dev.yml # Development mode configuration\n├── Dockerfile # Production image\n├── Dockerfile.dev # Development image\n├── pyproject.toml # Project dependencies\n├── uv.lock # Dependency lock\n├── env.example # Environment variable template\n├── CLAUDE.md # Claude AI guide\n└── README.md # This documentation\n```\n\n---\n\n## 🐛 Troubleshooting\n\n### Common Issues\n\n<details>\n<summary><b>Q1: What to do if service fails to start?</b></summary>\n\n```bash\n# View detailed logs\ndocker compose logs\n\n# Check port usage\nnetstat -tulpn | grep 8000\n\n# Check Docker status\ndocker ps -a\n```\n</details>\n\n<details>\n<summary><b>Q2: multimodal_processed error?</b></summary>\n\n**Note**: This issue has been fixed in LightRAG 1.4.9.4+. If you encounter this error, your version is outdated.\n\n**Solution**:\n```bash\n# Option 1: Upgrade to latest version (recommended)\n# Modify LightRAG version in pyproject.toml\n# lightrag = \"^1.4.9.4\"\n\n# Rebuild image\ndocker compose down\ndocker compose up -d --build\n\n# Option 2: Clean old data (temporary solution)\nrm -rf ./rag_local_storage\ndocker compose restart\n```\n</details>\n\n<details>\n<summary><b>Q3: File upload returns 400 error?</b></summary>\n\nCheck:\n- File format supported (PDF, DOCX, PNG, JPG, etc.)\n- File size exceeds 100MB\n- File is empty\n\n```bash\n# View supported formats\ncurl http://localhost:8000/docs\n```\n</details>\n\n<details>\n<summary><b>Q3.5: Embedding dimension error?</b></summary>\n\nIf you encounter dimension-related errors, need to clean data and rebuild:\n\n```bash\n# Stop services\ndocker compose down\n\n# Delete all volumes (clear database)\ndocker volume rm rag-api_dragonflydb_data rag-api_qdrant_data rag-api_memgraph_data\n\n# Modify EMBEDDING_DIM in .env\nEMBEDDING_DIM=1024 # or 4096, must match the model\n\n# Restart\ndocker compose up -d\n```\n</details>\n\n<details>\n<summary><b>Q4: Query is very slow (>30 seconds)?</b></summary>\n\nOptimization suggestions:\n1. Use `naive` or `hybrid` mode instead of `mix`\n2. Increase `MAX_ASYNC` parameter (in `.env`)\n3. Reduce `TOP_K` and `CHUNK_TOP_K`\n4. Enable Reranker\n\n```bash\n# Modify .env\nMAX_ASYNC=8\nTOP_K=20\nCHUNK_TOP_K=10\n```\n</details>\n\n<details>\n<summary><b>Q5: Out of memory (OOM)?</b></summary>\n\nIf using local MinerU:\n```bash\n# Switch to remote mode\n# Modify in .env\nMINERU_MODE=remote\nMINERU_API_TOKEN=your_token\n\n# Or limit concurrency\nDOCUMENT_PROCESSING_CONCURRENCY=1\n```\n</details>\n\n<details>\n<summary><b>Q6: Tasks lost after container restart?</b></summary>\n\n**Problem Symptoms**:\n- Cannot query previous task status after container restart\n- Tasks disappear after tenant instance evicted by LRU\n\n**Solution**: Enable Redis task storage\n\n```bash\n# Modify .env\nTASK_STORE_STORAGE=redis\n\n# Restart services\ndocker compose restart\n\n# Verify\ndocker compose logs api | grep TaskStore\n# Should see: ✅ TaskStore: Redis connection successful\n```\n\n**Configuration Description**:\n- `memory` mode: In-memory storage, data lost after restart (default, suitable for development)\n- `redis` mode: Persistent storage, supports container restart and instance rebuild (production recommended)\n\n**TTL Strategy** (Redis mode auto-cleanup):\n- completed tasks: 24 hours\n- failed tasks: 24 hours\n- pending/processing tasks: 6 hours\n</details>\n\n<details>\n<summary><b>Q7: VLM mode processing failed?</b></summary>\n\n**Check Items**:\n1. **vision_model_func not configured**\n - Check logs:`vision_model_func not found, fallback to off mode`\n - Ensure LLM API is configured in `.env`\n\n2. **Image file does not exist**\n - Check logs:`Image file not found: xxx`\n - Possibly corrupted MinerU ZIP or extraction failed\n\n3. **Timeout error**\n - `full` mode may timeout on large files\n - Suggestion: Use `selective` mode first, or increase `VLM_TIMEOUT`\n\n```bash\n# Modify .env\nVLM_TIMEOUT=300 # Increase to 5 minutes\nRAG_VLM_MODE=selective # downgrade to selective\n```\n\n**Debugging Tips**:\n```bash\n# View detailed logs\ndocker compose logs -f | grep VLM\n\n# Test single file\ncurl -X POST 'http://localhost:8000/insert?tenant_id=test&doc_id=test&vlm_mode=off' \\\n -F 'file=@test.pdf'\n```\n</details>\n\n### Performance Tuning Recommendations\n\n| Scenario | MAX_ASYNC | TOP_K | CHUNK_TOP_K | MINERU_MODE |\n|------|-----------|-------|-------------|-------------|\n| Fast response | 8 | 10 | 5 | remote |\n| Balanced mode | 8 | 20 | 10 | remote |\n| High accuracy | 4 | 60 | 20 | remote |\n| Resource limited | 4 | 20 | 10 | remote |\n\n---\n\n## 📖 Documentation\n\n- [📘 Architecture Design Documentation](docs/ARCHITECTURE.md) - Detailed system architecture and design concepts\n- [📗 Usage Guide](docs/USAGE.md) - Complete API usage documentation and examples\n- [📙 Deployment Mode Description](docs/DEPLOY_MODES.md) - Production mode vs Development mode\n- [📕 PR Workflow](docs/PR_WORKFLOW.md) - Process guide for code contribution\n- [📔 External Storage Deployment](docs/DEPLOYMENT_EXTERNAL_STORAGE.md) - Redis/PostgreSQL/Neo4j configuration\n- [📊 API Comparison Analysis](docs/API_COMPARISON.md) - rag-api vs LightRAG official API comparison\n- [🌐 WebUI Integration Guide](docs/LIGHTRAG_WEBUI_INTEGRATION.md) - Knowledge graph visualization integration\n\n---\n\n## 🤝 Contributing\n\nWe welcome all forms of contribution!\n\n### How to Contribute\n\n1. **Fork the project**\n\n```bash\ngit clone https://github.com/BukeLy/rag-api.git\ncd rag-api\n```\n\n2. **Create feature branch**\n\n```bash\ngit checkout -b feature/your-feature-name\n```\n\n3. **Development and Testing**\n\n```bash\n# Install dependencies\nuv sync\n\n# Run tests\nuv run pytest\n\n# Code formatting\nuv run black .\nuv run isort .\n```\n\n4. **Submit code**\n\n```bash\ngit add .\ngit commit -m \"feat: Add new feature\"\ngit push origin feature/your-feature-name\n```\n\n5. **Create Pull Request**\n\nCreate a PR on GitHub with detailed description of your changes.\n\n### Commit Conventions\n\nUse semantic commit messages:\n\n- `feat:` New feature\n- `fix:` Bug fix\n- `docs:` Documentation update\n- `style:` Code formatting\n- `refactor:` Code refactoring\n- `perf:` Performance optimization\n- `test:` Testing\n- `chore:` Build/tools\n\nSee [PR Workflow Documentation](docs/PR_WORKFLOW.md)。\n\n---\n\n## 📄 License\n\nThis project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.\n\n---\n\n## 🙏 Acknowledgments\n\nThis project is built on the following excellent open source projects:\n\n- [LightRAG](https://github.com/HKUDS/LightRAG) - Efficient knowledge graph RAG framework\n- [RAG-Anything](https://github.com/your-org/rag-anything) - Multimodal document parsing\n- [MinerU](https://github.com/opendatalab/MinerU) - Powerful PDF parsing tool\n- [Docling](https://github.com/DS4SD/docling) - Lightweight document parsing\n- [FastAPI](https://fastapi.tiangolo.com/) - Modern Python web framework\n\nSpecial thanks to all contributors and users for their support! 🎉\n\n---\n\n## 📬 Contact Us\n\n- **GitHub**: [@BukeLy](https://github.com/BukeLy)\n- **Email**: buledream233@gmail.com\n- **Issues**: [Submit Issue](https://github.com/BukeLy/rag-api/issues)\n- **Discussions**: [Join Discussion](https://github.com/BukeLy/rag-api/discussions)\n\n---\n\n<div align=\"center\">\n\n**⭐ If this project helps you, please give it a Star!**\n\nMade with ❤️ by [BukeLy](https://github.com/BukeLy)\n\n© 2025 RAG API. All rights reserved.\n\n</div>\n"}}, "README.zh-CN.md": {"f7e2565a6faa09c79be1148c9087ba34841ae066": {"t": 1785674112.7336779, "n": 24962, "h": "ea27039828134ee1", "c": "<div align=\"center\">\n\n# 🚀 RAG API\n\n**多租户多模态文档智能检索系统**\n\n基于 RAG-Anything 和 LightRAG 构建的企业级 RAG 服务\n\n[](https://github.com/BukeLy/rag-api/actions/workflows/ci-basic.yml)\n[](https://www.python.org/)\n[](https://fastapi.tiangolo.com/)\n[](https://github.com/HKUDS/LightRAG)\n[](https://www.docker.com/)\n[](LICENSE)\n\n[English](README.md) | [简体中文](README.zh-CN.md)\n\n[功能特性](#-功能特性) • [快速开始](#-快速开始) • [架构设计](#-架构设计) • [API 文档](#-api-文档) • [部署指南](#-部署指南)\n\n</div>\n\n---\n\n## 📖 项目简介\n\nRAG API 是一个企业级的检索增强生成(RAG)服务,结合了 **RAG-Anything** 的强大文档解析能力和 **LightRAG** 的高效知识图谱检索技术,为您的文档提供智能问答能力。\n\n### 🎯 核心亮点\n\n- 🏢 **多租户隔离** - 完整的租户数据隔离,支持企业级多租户场景\n- 🎨 **多模态解析** - 支持 PDF、Word、图片等多种格式,OCR、表格、公式全覆盖\n- ⚡ **高性能检索** - 基于知识图谱的混合检索,查询响应 6-15 秒\n- 🔄 **灵活部署** - 支持生产模式和开发模式,一键切换\n- 📦 **开箱即用** - Docker 一键部署,3 分钟启动服务\n- 🎛️ **多解析引擎** - DeepSeek-OCR(远程API) + MinerU(本地/远程API)+ Docling(快速)\n- 🎨 **RAG-Anything-VLM增强** - 三种模式(off/selective/full),深度理解图表内容\n- 💾 **任务持久化** - Redis 存储支持,容器重启/实例重建后任务可恢复\n\n---\n\n## ✨ 功能特性\n\n<table>\n<tr>\n<td width=\"50%\">\n\n### 📄 文档处理\n\n- ✅ **多格式支持**\n - PDF、Word、Excel、PPT\n - PNG、JPG、WebP 图片\n - TXT、Markdown 文本\n \n- ✅ **智能解析**\n - 纯文本(.txt, .md)→ 直接插入(极快 ~1秒,跳过解析器)\n - OCR 文字识别\n - 表格结构化提取\n - 数学公式识别\n - 版面分析\n\n- ✅ **RAG-Anything-VLM增强** 🆕\n - `off` - 仅 Markdown(最快)\n - `selective` - 选择性处理重要图表\n - `full` - 完整上下文增强处理\n - 智能过滤:有标题、大尺寸、首页内容\n - ⚠️ **仅支持 remote MinerU 模式,本地模式会调用RAG-Anything原生方法**\n\n- ✅ **批量处理**\n - 单次最多 100 个文件\n - 异步任务队列\n - 进度实时追踪\n\n</td>\n<td width=\"50%\">\n\n### 🔍 智能检索\n\n- ✅ **多模式查询**\n - `naive` - 向量检索(最快)\n - `local` - 本地图谱\n - `global` - 全局图谱\n - `hybrid` - 混合检索\n - `mix` - 完整检索(最准确)\n\n- ✅ **知识图谱**\n - 自动实体提取\n - 关系推理\n - 语义理解\n - 上下文增强\n\n- ✅ **外部存储**\n - DragonflyDB(KV 存储 + 任务存储)\n - Qdrant(向量存储)\n - Memgraph(图数据库)\n - 任务持久化(Redis 模式)\n\n</td>\n</tr>\n</table>\n\n---\n\n## 🏗️ 架构设计\n\n### 系统架构图\n\n```mermaid\ngraph TB\n subgraph \"客户端层\"\n Client[客户端应用]\n WebUI[Web 界面]\n end\n \n subgraph \"API 网关层\"\n FastAPI[FastAPI 服务]\n Auth[租户认证]\n end\n \n subgraph \"业务逻辑层\"\n TenantMgr[租户管理器]\n TaskQueue[任务队列]\n \n subgraph \"文档处理\"\n DeepSeekOCR[DeepSeek-OCR<br/>快速 OCR 80%场景]\n MinerU[MinerU 解析器<br/>复杂多模态]\n Docling[Docling 解析器<br/>快速轻量]\n FileRouter[智能路由<br/>复杂度评分选择]\n end\n \n subgraph \"RAG 引擎\"\n LightRAG[LightRAG 实例池<br/>LRU 缓存 50]\n KG[知识图谱引擎]\n Vector[向量检索引擎]\n end\n end\n \n subgraph \"存储层\"\n DragonflyDB[(DragonflyDB<br/>KV 存储)]\n Qdrant[(Qdrant<br/>向量数据库)]\n Memgraph[(Memgraph<br/>图数据库)]\n Local[(本地文件<br/>临时存储)]\n end\n \n subgraph \"外部服务\"\n LLM[LLM<br/>实体提取/生成]\n Embedding[Embedding<br/>向量化]\n Rerank[Rerank<br/>重排序]\n end\n \n Client --> FastAPI\n WebUI --> FastAPI\n FastAPI --> Auth\n Auth --> TenantMgr\n TenantMgr --> TaskQueue\n TenantMgr --> LightRAG\n \n TaskQueue --> FileRouter\n FileRouter --> DeepSeekOCR\n FileRouter --> MinerU\n FileRouter --> Docling\n DeepSeekOCR --> LightRAG\n MinerU --> LightRAG\n Docling --> LightRAG\n \n LightRAG --> KG\n LightRAG --> Vector\n \n KG --> DragonflyDB\n KG --> Memgraph\n Vector --> Qdrant\n LightRAG --> Local\n \n LightRAG --> LLM\n LightRAG --> Embedding\n Vector --> Rerank\n \n style FastAPI fill:#00C7B7\n style LightRAG fill:#FF6B6B\n style DeepSeekOCR fill:#5DADE2\n style MinerU fill:#4ECDC4\n style Docling fill:#95E1D3\n style TenantMgr fill:#F38181\n```\n\n### 多租户架构\n\n```mermaid\ngraph TB\n subgraph \"租户 A\"\n A_Config[租户 A 配置<br/>独立 API Key]\n A_Instance[LightRAG 实例 A<br/>专属 LLM/Embedding]\n A_Data[(租户 A 数据<br/>完全隔离)]\n A_Config --> A_Instance\n A_Instance --> A_Data\n end\n\n subgraph \"租户 B\"\n B_Config[租户 B 配置<br/>独立 API Key]\n B_Instance[LightRAG 实例 B<br/>专属 LLM/Embedding]\n B_Data[(租户 B 数据<br/>完全隔离)]\n B_Config --> B_Instance\n B_Instance --> B_Data\n end\n\n subgraph \"租户 C\"\n C_Config[使用全局配置]\n C_Instance[LightRAG 实例 C<br/>共享 LLM/Embedding]\n C_Data[(租户 C 数据<br/>完全隔离)]\n C_Config --> C_Instance\n C_Instance --> C_Data\n end\n\n Pool[实例池管理器<br/>LRU Cache + 配置隔离]\n Global[全局配置<br/>默认 API Key]\n\n Pool --> A_Instance\n Pool --> B_Instance\n Pool --> C_Instance\n\n C_Config -.降级.-> Global\n\n style Pool fill:#F38181\n style Global fill:#95E1D3\n style A_Config fill:#FFD93D\n style B_Config fill:#FFD93D\n style C_Config fill:#E8E8E8\n```\n\n### 核心技术栈\n\n<table>\n<tr>\n<td width=\"33%\">\n\n**🔧 框架 & 运行时**\n- FastAPI 0.115+\n- Python 3.11+\n- Uvicorn\n- Docker & Docker Compose\n\n</td>\n<td width=\"33%\">\n\n**🧠 AI & RAG**\n- LightRAG 1.4.9.4\n- RAG-Anything\n- MinerU (PDF-Extract-Kit)\n- Docling\n\n</td>\n<td width=\"33%\">\n\n**💾 存储 & 数据库**\n- DragonflyDB(Redis 协议兼容)\n- Qdrant(向量数据库)\n- Memgraph(图数据库)\n- 本地文件系统\n\n</td>\n</tr>\n</table>\n\n---\n\n## 🚀 快速开始\n\n### 方式一:一键部署(推荐)\n\n适合生产环境和测试环境:\n\n```bash\n# 1. 克隆项目\ngit clone https://github.com/BukeLy/rag-api.git\ncd rag-api\n\n# 2. 配置环境变量\ncp env.example .env\nnano .env # 填入你的 API 密钥\n\n# 3. 运行部署脚本\nchmod +x deploy.sh\n./deploy.sh\n\n# 选择部署模式:\n# 1) 生产模式 (Production) - 标准容器部署\n# 2) 开发模式 (Development) - 代码热重载\n\n# 4. 验证服务\ncurl http://localhost:8000/\n```\n\n**访问 Swagger 文档:** http://localhost:8000/docs\n\n### 方式二:Docker Compose\n\n#### 生产模式\n\n```bash\n# 配置环境变量\ncp env.example .env\nnano .env\n\n# 启动服务\ndocker compose -f docker-compose.yml up -d\n\n# 查看日志\ndocker compose -f docker-compose.yml logs -f\n```\n\n#### 开发模式(代码热重载)\n\n```bash\n# 启动开发环境\ndocker compose -f docker-compose.dev.yml up -d\n\n# 或使用快捷脚本\n./scripts/dev.sh\n\n# 修改代码会自动重载,无需重启\n```\n\n### 方式三:本地开发\n\n```bash\n# 安装 uv (Python 包管理器)\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n\n# 安装依赖\nuv sync\n\n# 配置环境变量\ncp env.example .env\nnano .env\n\n# 启动服务\nuv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload\n```\n\n### 环境变量配置\n\n最小配置(必填):\n\n```bash\n# LLM 配置(功能导向命名)\nLLM_API_KEY=your_llm_api_key\nLLM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3\nLLM_MODEL=ep-xxx-xxx\n# LLM_REQUESTS_PER_MINUTE=800 # 速率限制(可选)\n# LLM_TOKENS_PER_MINUTE=40000 # 速率限制(可选)\n# LLM_MAX_ASYNC=8 # 【可选,专家模式】手动指定并发数\n# # 未设置时自动计算: min(RPM, TPM/3500) = 11\n\n# Embedding 配置(功能导向命名)\nEMBEDDING_API_KEY=your_embedding_api_key\nEMBEDDING_BASE_URL=https://api.siliconflow.cn/v1\nEMBEDDING_MODEL=Qwen/Qwen3-Embedding-0.6B\nEMBEDDING_DIM=1024\n# EMBEDDING_MAX_ASYNC=32 # 【可选,专家模式】未设置时自动计算: 800\n\n# MinerU 模式(推荐远程模式)\nMINERU_MODE=remote\nMINERU_API_TOKEN=your_token\nMINERU_HTTP_TIMEOUT=60 # MinerU 下载超时(秒,默认 60)\nFILE_SERVICE_BASE_URL=http://your-ip:8000\n\n# VLM 图表增强配置 🆕\n# ⚠️ 注意:仅在 MINERU_MODE=remote 时生效\nRAG_VLM_MODE=off # off / selective / full\nRAG_IMPORTANCE_THRESHOLD=0.5 # 重要性阈值(selective 模式)\nRAG_CONTEXT_WINDOW=2 # 上下文窗口(full 模式)\nRAG_CONTEXT_MODE=page # page / chunk\nRAG_MAX_CONTEXT_TOKENS=3000 # 最大上下文 tokens\n\n# 任务存储配置 🆕\nTASK_STORE_STORAGE=redis # memory / redis(生产推荐 redis)\n\n# 文档插入验证配置 🆕\nDOC_INSERT_VERIFICATION_TIMEOUT=300 # 验证超时时间(秒,默认 5 分钟)\nDOC_INSERT_VERIFICATION_POLL_INTERVAL=0.5 # 轮询间隔(秒,默认 500ms)\n\n# 模型调用超时配置 🆕\nMODEL_CALL_TIMEOUT=90 # 模型调用最大超时(秒,默认 90)\n```\n\n**⚡ 自动并发数计算**:\n- **LLM**: 未设置 `LLM_MAX_ASYNC` 时,自动计算为 `min(RPM, TPM/3500)` ≈ 11\n- **Embedding**: 未设置 `EMBEDDING_MAX_ASYNC` 时,自动计算为 `min(RPM, TPM/500)` ≈ 800\n- **Rerank**: 未设置 `RERANK_MAX_ASYNC` 时,自动计算为 `min(RPM, TPM/500)` ≈ 800\n\n**✅ 推荐**: 不设置 `*_MAX_ASYNC`,让系统自动计算,彻底避免 429 错误\n\n完整配置参考 `env.example`。\n\n---\n\n## 📚 API 文档\n\n### 核心接口\n\n#### 1️⃣ 上传文档\n\n```bash\n# 单文件上传(默认模式)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc1\" \\\n -F \"file=@document.pdf\" \\\n -F \"parser=auto\"\n\n# VLM 图表增强模式 🆕\n# off: 仅 Markdown(最快,默认)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc2&vlm_mode=off\" \\\n -F \"file=@document.pdf\"\n\n# selective: 选择性处理重要图表(平衡性能和质量)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc3&vlm_mode=selective\" \\\n -F \"file=@document.pdf\"\n\n# full: 完整 RAG-Anything 处理(最高质量,启用上下文增强)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=your_tenant&doc_id=doc4&vlm_mode=full\" \\\n -F \"file=@document.pdf\"\n\n# 返回\n{\n \"task_id\": \"task-xxx-xxx\",\n \"doc_id\": \"doc1\",\n \"filename\": \"document.pdf\",\n \"vlm_mode\": \"off\",\n \"status\": \"pending\"\n}\n```\n\n#### 2️⃣ 批量上传\n\n```bash\ncurl -X POST \"http://localhost:8000/batch?tenant_id=your_tenant\" \\\n -F \"files=@doc1.pdf\" \\\n -F \"files=@doc2.docx\" \\\n -F \"files=@image.png\"\n\n# 返回\n{\n \"batch_id\": \"batch-xxx-xxx\",\n \"total_files\": 3,\n \"accepted_files\": 3,\n \"tasks\": [...]\n}\n```\n\n#### 3️⃣ 智能查询(Query API v2.0)\n\n**新增高级功能**:\n- ✨ **对话历史**:支持多轮对话上下文\n- ✨ **自定义提示词**:定制回答风格\n- ✨ **响应格式控制**:paragraph/list/json\n- ✨ **关键词精准检索**:hl_keywords/ll_keywords\n- ✨ **流式输出**:实时查看生成过程\n\n```bash\n# 基础查询\ncurl -X POST \"http://localhost:8000/query?tenant_id=your_tenant\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"文档中的核心观点是什么?\",\n \"mode\": \"hybrid\"\n }'\n\n# 高级查询(多轮对话 + 自定义提示词)\ncurl -X POST \"http://localhost:8000/query?tenant_id=your_tenant\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"能详细展开第二点吗?\",\n \"mode\": \"hybrid\",\n \"conversation_history\": [\n {\"role\": \"user\", \"content\": \"有哪些要点?\"},\n {\"role\": \"assistant\", \"content\": \"主要有三点...\"}\n ],\n \"user_prompt\": \"请用专业的学术语言回答\",\n \"response_type\": \"list\"\n }'\n\n# 流式查询(SSE)\ncurl -N -X POST \"http://localhost:8000/query/stream?tenant_id=your_tenant\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"文档中的核心观点是什么?\",\n \"mode\": \"hybrid\"\n }'\n\n# 返回(实时流式输出)\ndata: {\"chunk\": \"根据\", \"done\": false}\ndata: {\"chunk\": \"文档内容\", \"done\": false}\ndata: {\"done\": true}\n```\n\n#### 4️⃣ 任务状态查询\n\n```bash\ncurl \"http://localhost:8000/task/task-xxx-xxx?tenant_id=your_tenant\"\n\n# 返回\n{\n \"task_id\": \"task-xxx-xxx\",\n \"status\": \"completed\",\n \"progress\": 100,\n \"result\": {...}\n}\n```\n\n#### 5️⃣ 租户管理\n\n```bash\n# 获取租户统计\ncurl \"http://localhost:8000/tenants/stats?tenant_id=your_tenant\"\n\n# 清除租户缓存\ncurl -X DELETE \"http://localhost:8000/tenants/cache?tenant_id=your_tenant\"\n\n# 查看实例池状态(管理员)\ncurl \"http://localhost:8000/tenants/pool/stats\"\n```\n\n### VLM 模式对比 🆕\n\n| 模式 | 速度 | 质量 | 资源消耗 | 适用场景 |\n|------|------|------|----------|---------|\n| `off` | ⚡⚡⚡⚡⚡ | ⭐⭐⭐ | 极低 | 纯文本文档、快速批量处理 |\n| `selective` | ⚡⚡⚡⚡ | ⭐⭐⭐⭐ | 低 | 包含关键图表的文档(推荐) |\n| `full` | ⚡⚡ | ⭐⭐⭐⭐⭐ | 高 | 图表密集的研究报告、论文 |\n\n**处理时间估算**(以 20 页 PDF 为例):\n- `off`: ~10 秒(仅 Markdown)\n- `selective`: ~30 秒(5-10 个重要图表)\n- `full`: ~120 秒(完整上下文处理)\n\n### 查询模式对比\n\n| 模式 | 速度 | 准确度 | 适用场景 |\n|------|------|--------|---------|\n| `naive` | ⚡⚡⚡⚡⚡ | ⭐⭐⭐ | 简单问答,快速检索 |\n| `local` | ⚡⚡⚡⚡ | ⭐⭐⭐⭐ | 局部实体关系查询 |\n| `global` | ⚡⚡⚡ | ⭐⭐⭐⭐ | 全局知识图谱推理 |\n| `hybrid` | ⚡⚡⚡ | ⭐⭐⭐⭐⭐ | 混合检索(推荐) |\n| `mix` | ⚡⚡ | ⭐⭐⭐⭐⭐ | 复杂问题,深度分析 |\n\n### Query API v2.0 高级参数\n\n| 参数 | 类型 | 说明 | 示例 |\n|------|------|------|------|\n| `conversation_history` | List[Dict] | 多轮对话上下文 | `[{\"role\": \"user\", \"content\": \"...\"}]` |\n| `user_prompt` | str | 自定义提示词 | \"请用专业的学术语言回答\" |\n| `response_type` | str | 响应格式 | \"paragraph\", \"list\", \"json\" |\n| `hl_keywords` | List[str] | 高优先级关键词 | `[\"人工智能\", \"机器学习\"]` |\n| `ll_keywords` | List[str] | 低优先级关键词 | `[\"应用\", \"案例\"]` |\n| `only_need_context` | bool | 仅返回上下文(调试) | `true` |\n| `max_entity_tokens` | int | 实体 Token 限制 | `6000` |\n\n完整 API 文档访问:http://localhost:8000/docs\n\n---\n\n## 🎯 使用示例\n\n### Python SDK\n\n```python\nimport requests\n\n# 配置\nBASE_URL = \"http://localhost:8000\"\nTENANT_ID = \"your_tenant\"\n\n# 上传文档\nwith open(\"document.pdf\", \"rb\") as f:\n response = requests.post(\n f\"{BASE_URL}/insert\",\n params={\"tenant_id\": TENANT_ID, \"doc_id\": \"doc1\"},\n files={\"file\": f}\n )\n task_id = response.json()[\"task_id\"]\n print(f\"Task ID: {task_id}\")\n\n# 查询\nresponse = requests.post(\n f\"{BASE_URL}/query\",\n params={\"tenant_id\": TENANT_ID},\n json={\n \"query\": \"文档的主要内容是什么?\",\n \"mode\": \"hybrid\",\n \"top_k\": 10\n }\n)\nresult = response.json()\nprint(f\"Answer: {result['answer']}\")\n```\n\n### cURL 完整示例\n\n```bash\n# 1. 上传 PDF 文档\nTASK_ID=$(curl -X POST \"http://localhost:8000/insert?tenant_id=demo&doc_id=report\" \\\n -F \"file=@report.pdf\" | jq -r '.task_id')\n\necho \"Task ID: $TASK_ID\"\n\n# 2. 等待处理完成\nwhile true; do\n STATUS=$(curl -s \"http://localhost:8000/task/$TASK_ID?tenant_id=demo\" | jq -r '.status')\n echo \"Status: $STATUS\"\n if [ \"$STATUS\" = \"completed\" ] || [ \"$STATUS\" = \"failed\" ]; then\n break\n fi\n sleep 2\ndone\n\n# 3. 查询文档内容\ncurl -X POST \"http://localhost:8000/query?tenant_id=demo\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"这份报告的主要结论是什么?\",\n \"mode\": \"hybrid\"\n }' | jq '.answer'\n```\n\n---\n\n## 🛠️ 部署指南\n\n### 系统要求\n\n**最小配置:**\n- CPU: 2 核\n- 内存: 4GB\n- 磁盘: 40GB SSD\n- 系统: Ubuntu 20.04+ / Debian 11+ / CentOS 8+\n\n**推荐配置(生产环境):**\n- CPU: 4 核\n- 内存: 8GB\n- 磁盘: 100GB SSD\n- 系统: Ubuntu 22.04 LTS\n\n### 服务器部署\n\n#### 阿里云/腾讯云快速部署\n\n```bash\n# SSH 登录服务器\nssh root@your-server-ip\n\n# 克隆项目\ngit clone https://github.com/BukeLy/rag-api.git\ncd rag-api\n\n# 运行一键部署脚本\nchmod +x deploy.sh\n./deploy.sh\n\n# 脚本会自动:\n# 1. 安装 Docker 和 Docker Compose\n# 2. 配置环境变量\n# 3. 优化系统参数\n# 4. 启动服务\n# 5. 验证健康状态\n```\n\n#### 外部存储配置\n\n支持 DragonflyDB + Qdrant + Memgraph 外部存储(默认已启用):\n\n```bash\n# 在 .env 中配置\nUSE_EXTERNAL_STORAGE=true\n\n# DragonflyDB 配置(KV 存储)\nKV_STORAGE=RedisKVStorage\nREDIS_URI=redis://dragonflydb:6379/0\n\n# Qdrant 配置(向量存储)\nVECTOR_STORAGE=QdrantVectorDBStorage\nQDRANT_URL=http://qdrant:6333\n\n# Memgraph 配置(图存储)\nGRAPH_STORAGE=MemgraphStorage\nMEMGRAPH_URI=bolt://memgraph:7687\nMEMGRAPH_USERNAME=\nMEMGRAPH_PASSWORD=\n```\n\n详细配置参考 [外部存储部署文档](docs/DEPLOYMENT_EXTERNAL_STORAGE.md)。\n\n### Docker Compose 配置\n\n项目提供两个配置文件:\n\n| 文件 | 用途 | 特点 |\n|------|------|------|\n| `docker-compose.yml` | 生产模式 | 代码打包到镜像,性能最优 |\n| `docker-compose.dev.yml` | 开发模式 | 代码外挂,支持热重载 |\n\n选择配置文件:\n\n```bash\n# 生产模式\ndocker compose -f docker-compose.yml up -d\n\n# 开发模式\ndocker compose -f docker-compose.dev.yml up -d\n```\n\n### 性能优化\n\n#### 调优参数\n\n在 `.env` 中配置:\n\n```bash\n# ⚡ 并发控制(推荐:使用自动计算)\n# LLM_MAX_ASYNC=8 # 【专家模式】手动指定 LLM 并发数\n# # 未设置时自动计算: min(RPM, TPM/3500) ≈ 11\n# EMBEDDING_MAX_ASYNC=32 # 【专家模式】手动指定 Embedding 并发数\n# # 未设置时自动计算: min(RPM, TPM/500) ≈ 800\n# RERANK_MAX_ASYNC=16 # 【专家模式】手动指定 Rerank 并发数\n# # 未设置时自动计算: min(RPM, TPM/500) ≈ 800\n\n# 检索数量(影响查询质量和速度)\nTOP_K=20 # 实体/关系检索数量\nCHUNK_TOP_K=10 # 文本块检索数量\n\n# 文档处理并发\nDOCUMENT_PROCESSING_CONCURRENCY=10 # 远程模式可设高,本地模式设为 1\n```\n\n**🎯 并发数配置建议**:\n- **推荐方式**:不设置 `*_MAX_ASYNC`,让系统根据 TPM/RPM 自动计算\n- **专家模式**:如果需要手动控制,可设置 `LLM_MAX_ASYNC` 等参数\n- **优势**:自动计算可彻底避免 429 错误(TPM limit reached)\n\n#### 模式选择\n\n- **MinerU 远程模式(推荐)**:高并发,节省资源\n- **MinerU 本地模式**:需要 GPU,内存占用高\n- **Docling 模式**:快速轻量,适合简单文档\n\n---\n\n## 🏢 多租户使用\n\n### 租户隔离\n\n每个租户拥有:\n- ✅ 独立的 LightRAG 实例\n- ✅ 隔离的数据存储空间\n- ✅ 独立的向量索引\n- ✅ 专属的知识图谱\n- ✅ **独立的服务配置**(LLM、Embedding、Rerank、DeepSeek-OCR、MinerU)🆕\n\n### 租户配置管理 🆕\n\n每个租户可以独立配置 5 个服务,支持配置热重载:\n\n```bash\n# 1️⃣ 为租户 A 配置独立的 DeepSeek-OCR API key\ncurl -X PUT \"http://localhost:8000/tenants/tenant_a/config\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"ds_ocr_config\": {\n \"api_key\": \"sk-tenant-a-ds-ocr-key\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 90\n }\n }'\n\n# 2️⃣ 为租户 B 配置独立的 MinerU API token\ncurl -X PUT \"http://localhost:8000/tenants/tenant_b/config\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"mineru_config\": {\n \"api_token\": \"tenant-b-mineru-token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\"\n }\n }'\n\n# 3️⃣ 同时配置多个服务(LLM + Embedding + DeepSeek-OCR)\ncurl -X PUT \"http://localhost:8000/tenants/tenant_c/config\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"llm_config\": {\n \"api_key\": \"sk-tenant-c-llm-key\",\n \"model\": \"gpt-4\"\n },\n \"embedding_config\": {\n \"api_key\": \"sk-tenant-c-embedding-key\",\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"dim\": 1024\n },\n \"ds_ocr_config\": {\n \"api_key\": \"sk-tenant-c-ds-ocr-key\"\n }\n }'\n\n# 4️⃣ 查询租户配置(API key 自动脱敏)\ncurl \"http://localhost:8000/tenants/tenant_a/config\"\n\n# 返回示例\n{\n \"tenant_id\": \"tenant_a\",\n \"ds_ocr_config\": {\n \"api_key\": \"sk-***-key\", // 自动脱敏\n \"timeout\": 90\n },\n \"merged_config\": {\n \"llm\": {...}, // 使用全局配置\n \"embedding\": {...}, // 使用全局配置\n \"rerank\": {...}, // 使用全局配置\n \"ds_ocr\": {...}, // 使用租户配置\n \"mineru\": {...} // 使用全局配置\n }\n}\n\n# 5️⃣ 刷新配置缓存(配置热重载)\ncurl -X POST \"http://localhost:8000/tenants/tenant_a/config/refresh\"\n\n# 6️⃣ 删除租户配置(恢复使用全局配置)\ncurl -X DELETE \"http://localhost:8000/tenants/tenant_a/config\"\n```\n\n**支持的配置项**:\n\n| 服务 | 配置字段 | 说明 |\n|------|---------|------|\n| **LLM** | `llm_config` | 模型、API key、base_url 等 |\n| **Embedding** | `embedding_config` | 模型、API key、维度等 |\n| **Rerank** | `rerank_config` | 模型、API key等 |\n| **DeepSeek-OCR** | `ds_ocr_config` | API key、超时、模式等 |\n| **MinerU** | `mineru_config` | API token、版本、超时等 |\n\n**配置优先级**:租户配置 > 全局配置\n\n**使用场景**:\n- 🔐 **多租户 SaaS**:每个租户使用自己的 API key\n- 💰 **按量计费**:通过独立 API key 跟踪租户使用量\n- 🎯 **差异化服务**:不同租户使用不同的模型(GPT-4 vs GPT-3.5)\n- 🧪 **A/B 测试**:对比不同模型/参数的效果\n\n### 使用方式\n\n所有 API 都需要提供 `tenant_id` 参数:\n\n```bash\n# 租户 A 上传文档\ncurl -X POST \"http://localhost:8000/insert?tenant_id=tenant_a&doc_id=doc1\" \\\n -F \"file=@doc.pdf\"\n\n# 租户 B 上传文档(完全隔离)\ncurl -X POST \"http://localhost:8000/insert?tenant_id=tenant_b&doc_id=doc1\" \\\n -F \"file=@doc.pdf\"\n\n# 租户 A 查询(只能查到自己的文档)\ncurl -X POST \"http://localhost:8000/query?tenant_id=tenant_a\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"query\": \"文档内容\", \"mode\": \"hybrid\"}'\n```\n\n### 实例池管理\n\n- **容量**:最多缓存 50 个租户实例\n- **策略**:LRU(最近最少使用)自动清理\n- **配置隔离**:每个租户可使用独立的 LLM、Embedding、解析器配置\n\n---\n\n## 📊 监控与维护\n\n### 常用命令\n\n```bash\n# 查看服务状态\ndocker compose ps\n\n# 查看实时日志\ndocker compose logs -f\n\n# 重启服务\ndocker compose restart\n\n# 停止服务\ndocker compose down\n\n# 查看资源使用\ndocker stats\n\n# 清理 Docker 资源\ndocker system prune -f\n```\n\n### 维护脚本\n\n```bash\n# 监控服务健康状态\n./scripts/monitor.sh\n\n# 备份数据\n./scripts/backup.sh\n\n# 更新服务\n./scripts/update.sh\n\n# 性能测试\n./scripts/test_concurrent_perf.sh\n\n# 性能监控\n./scripts/monitor_performance.sh\n```\n\n### 健康检查\n\n```bash\n# 完整健康检查(推荐)\n./scripts/health_check.sh\n./scripts/health_check.sh --verbose # 详细输出\n\n# API 健康检查\ncurl http://localhost:8000/\n\n# 租户统计\ncurl \"http://localhost:8000/tenants/stats?tenant_id=your_tenant\"\n\n# 实例池状态\ncurl \"http://localhost:8000/tenants/pool/stats\"\n```\n\n---\n\n## 🗂️ 项目结构\n\n```\nrag-api/\n├── main.py # FastAPI 应用入口\n├── api/ # API 路由模块\n│ ├── __init__.py # 路由聚合\n│ ├── insert.py # 文档上传(单/批量)\n│ ├── query.py # 智能查询\n│ ├── task.py # 任务状态查询\n│ ├── tenant.py # 租户管理\n│ ├── files.py # 文件服务\n│ ├── models.py # Pydantic 模型\n│ └── task_store.py # 任务存储\n├── src/ # 核心业务逻辑\n│ ├── rag.py # LightRAG 生命周期管理\n│ ├── multi_tenant.py # 多租户实例管理器\n│ ├── tenant_deps.py # 租户依赖注入\n│ ├── logger.py # 统一日志\n│ ├── metrics.py # 性能指标\n│ ├── file_url_service.py # 临时文件服务\n│ ├── mineru_client.py # MinerU 客户端\n│ └── mineru_result_processor.py # 结果处理\n├── docs/ # 文档\n│ ├── ARCHITECTURE.md # 架构设计文档\n│ ├── USAGE.md # 详细使用指南\n│ ├── DEPLOY_MODES.md # 部署模式说明\n│ ├── PR_WORKFLOW.md # PR 工作流程\n│ └── ...\n├── scripts/ # 维护脚本\n│ ├── dev.sh # 开发模式快捷启动\n│ ├── monitor.sh # 服务监控\n│ ├── backup.sh # 数据备份\n│ ├── update.sh # 服务更新\n│ └── ...\n├── deploy.sh # 一键部署脚本\n├── docker-compose.yml # 生产模式配置\n├── docker-compose.dev.yml # 开发模式配置\n├── Dockerfile # 生产镜像\n├── Dockerfile.dev # 开发镜像\n├── pyproject.toml # 项目依赖\n├── uv.lock # 依赖锁定\n├── env.example # 环境变量模板\n├── CLAUDE.md # Claude AI 指引\n└── README.md # 本文档\n```\n\n---\n\n## 🐛 故障排查\n\n### 常见问题\n\n<details>\n<summary><b>Q1: 服务启动失败怎么办?</b></summary>\n\n```bash\n# 查看详细日志\ndocker compose logs\n\n# 检查端口占用\nnetstat -tulpn | grep 8000\n\n# 检查 Docker 状态\ndocker ps -a\n```\n</details>\n\n<details>\n<summary><b>Q2: multimodal_processed 错误?</b></summary>\n\n**注意**:此问题已在 LightRAG 1.4.9.4+ 版本中修复。如果遇到此错误,说明版本过旧。\n\n**解决方案**:\n```bash\n# 方案 1:升级到最新版本(推荐)\n# 修改 pyproject.toml 中的 LightRAG 版本\n# lightrag = \"^1.4.9.4\"\n\n# 重新构建镜像\ndocker compose down\ndocker compose up -d --build\n\n# 方案 2:清理旧数据(临时方案)\nrm -rf ./rag_local_storage\ndocker compose restart\n```\n</details>\n\n<details>\n<summary><b>Q3: 上传文件返回 400 错误?</b></summary>\n\n检查:\n- 文件格式是否支持(PDF、DOCX、PNG、JPG等)\n- 文件大小是否超过 100MB\n- 文件是否为空\n\n```bash\n# 查看支持的格式\ncurl http://localhost:8000/docs\n```\n</details>\n\n<details>\n<summary><b>Q3.5: Embedding 维度错误?</b></summary>\n\n如果遇到维度相关错误,需要清理数据并重建:\n\n```bash\n# 停止服务\ndocker compose down\n\n# 删除所有 volume(清空数据库)\ndocker volume rm rag-api_dragonflydb_data rag-api_qdrant_data rag-api_memgraph_data\n\n# 修改 .env 中的 EMBEDDING_DIM\nEMBEDDING_DIM=1024 # 或 4096,必须与模型匹配\n\n# 重新启动\ndocker compose up -d\n```\n</details>\n\n<details>\n<summary><b>Q4: 查询速度很慢(>30秒)?</b></summary>\n\n优化建议:\n1. 使用 `naive` 或 `hybrid` 模式而不是 `mix`\n2. 增加 `MAX_ASYNC` 参数(在 `.env` 中)\n3. 减小 `TOP_K` 和 `CHUNK_TOP_K`\n4. 启用 Reranker\n\n```bash\n# 修改 .env\nMAX_ASYNC=8\nTOP_K=20\nCHUNK_TOP_K=10\n```\n</details>\n\n<details>\n<summary><b>Q5: 内存不足(OOM)?</b></summary>\n\n如果使用本地 MinerU:\n```bash\n# 切换到远程模式\n# 在 .env 中修改\nMINERU_MODE=remote\nMINERU_API_TOKEN=your_token\n\n# 或限制并发\nDOCUMENT_PROCESSING_CONCURRENCY=1\n```\n</details>\n\n<details>\n<summary><b>Q6: 容器重启后任务丢失?</b></summary>\n\n**问题现象**:\n- 容器重启后无法查询之前的任务状态\n- 租户实例被 LRU 驱逐后任务消失\n\n**解决方案**:启用 Redis 任务存储\n\n```bash\n# 修改 .env\nTASK_STORE_STORAGE=redis\n\n# 重启服务\ndocker compose restart\n\n# 验证\ndocker compose logs api | grep TaskStore\n# 应该看到: ✅ TaskStore: Redis connection successful\n```\n\n**配置说明**:\n- `memory` 模式:内存存储,重启后数据丢失(默认,适合开发)\n- `redis` 模式:持久化存储,支持容器重启和实例重建(生产推荐)\n\n**TTL 策略**(Redis 模式自动清理):\n- completed 任务:24 小时\n- failed 任务:24 小时\n- pending/processing 任务:6 小时\n</details>\n\n<details>\n<summary><b>Q7: VLM 模式处理失败?</b></summary>\n\n**检查项**:\n1. **vision_model_func 未配置**\n - 检查日志:`vision_model_func not found, fallback to off mode`\n - 确保 `.env` 中配置了 LLM API(豆包)\n\n2. **图片文件不存在**\n - 检查日志:`Image file not found: xxx`\n - 可能是 MinerU ZIP 损坏或解压失败\n\n3. **超时错误**\n - `full` 模式处理大文件可能超时\n - 建议:先用 `selective` 模式,或增加 `VLM_TIMEOUT`\n\n```bash\n# 修改 .env\nVLM_TIMEOUT=300 # 增加到 5 分钟\nRAG_VLM_MODE=selective # 降级到 selective\n```\n\n**调试技巧**:\n```bash\n# 查看详细日志\ndocker compose logs -f | grep VLM\n\n# 测试单个文件\ncurl -X POST 'http://localhost:8000/insert?tenant_id=test&doc_id=test&vlm_mode=off' \\\n -F 'file=@test.pdf'\n```\n</details>\n\n### 性能调优建议\n\n| 场景 | MAX_ASYNC | TOP_K | CHUNK_TOP_K | MINERU_MODE |\n|------|-----------|-------|-------------|-------------|\n| 快速响应 | 8 | 10 | 5 | remote |\n| 平衡模式 | 8 | 20 | 10 | remote |\n| 高准确度 | 4 | 60 | 20 | remote |\n| 资源受限 | 4 | 20 | 10 | remote |\n\n---\n\n## 📖 文档\n\n- [📘 架构设计文档](docs/ARCHITECTURE.md) - 详细的系统架构和设计思路\n- [📗 使用指南](docs/USAGE.md) - 完整的 API 使用文档和示例\n- [📙 部署模式说明](docs/DEPLOY_MODES.md) - 生产模式 vs 开发模式\n- [📕 PR 工作流程](docs/PR_WORKFLOW.md) - 贡献代码的流程指南\n- [📔 外部存储部署](docs/DEPLOYMENT_EXTERNAL_STORAGE.md) - Redis/PostgreSQL/Neo4j 配置\n- [📊 API 对比分析](docs/API_COMPARISON.md) - rag-api vs LightRAG 官方 API 对比\n- [🌐 WebUI 集成指南](docs/LIGHTRAG_WEBUI_INTEGRATION.md) - 知识图谱可视化集成\n\n---\n\n## 🤝 贡献指南\n\n我们欢迎所有形式的贡献!\n\n### 如何贡献\n\n1. **Fork 项目**\n\n```bash\ngit clone https://github.com/BukeLy/rag-api.git\ncd rag-api\n```\n\n2. **创建功能分支**\n\n```bash\ngit checkout -b feature/your-feature-name\n```\n\n3. **开发和测试**\n\n```bash\n# 安装依赖\nuv sync\n\n# 运行测试\nuv run pytest\n\n# 代码格式化\nuv run black .\nuv run isort .\n```\n\n4. **提交代码**\n\n```bash\ngit add .\ngit commit -m \"feat: 添加新功能\"\ngit push origin feature/your-feature-name\n```\n\n5. **创建 Pull Request**\n\n在 GitHub 上创建 PR,详细描述你的更改。\n\n### Commit 规范\n\n使用语义化提交信息:\n\n- `feat:` 新功能\n- `fix:` Bug 修复\n- `docs:` 文档更新\n- `style:` 代码格式\n- `refactor:` 代码重构\n- `perf:` 性能优化\n- `test:` 测试相关\n- `chore:` 构建/工具\n\n详见 [PR 工作流程文档](docs/PR_WORKFLOW.md)。\n\n---\n\n## 📄 许可证\n\n本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。\n\n---\n\n## 🙏 致谢\n\n本项目基于以下优秀的开源项目构建:\n\n- [LightRAG](https://github.com/HKUDS/LightRAG) - 高效的知识图谱 RAG 框架\n- [RAG-Anything](https://github.com/your-org/rag-anything) - 多模态文档解析\n- [MinerU](https://github.com/opendatalab/MinerU) - 强大的 PDF 解析工具\n- [Docling](https://github.com/DS4SD/docling) - 轻量级文档解析\n- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架\n\n特别感谢所有贡献者和用户的支持! 🎉\n\n---\n\n## 📬 联系我们\n\n- **GitHub**: [@BukeLy](https://github.com/BukeLy)\n- **Email**: buledream233@gmail.com\n- **Issues**: [提交问题](https://github.com/BukeLy/rag-api/issues)\n- **Discussions**: [参与讨论](https://github.com/BukeLy/rag-api/discussions)\n\n---\n\n<div align=\"center\">\n\n**⭐ 如果这个项目对你有帮助,请给个 Star!**\n\nMade with ❤️ by [BukeLy](https://github.com/BukeLy)\n\n© 2025 RAG API. All rights reserved.\n\n</div>\n"}}, "api/tenant_config.py": {"f7e2565a6faa09c79be1148c9087ba34841ae066": {"t": 1785746867.08193, "n": 9335, "h": "40789c8b52c54b48", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] = None # 🆕 Custom Prompts 配置\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] # 🆕 Custom Prompts 配置\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 获取请求中实际设置的字段(区分\"未设置\"和\"设置为 null\")\n request_data = request.model_dump(exclude_unset=True)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置(只更新请求中出现的字段)\n config_data = existing_config.model_dump()\n for key in request_data:\n config_data[key] = request_data[key]\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n \"custom_prompts\": request.custom_prompts, # 🆕 Custom Prompts\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n custom_prompts=new_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )"}, "7cb8fbaf8fdbc281c89ad7898d8c00d86933fe0b": {"t": 1785768249.9090698, "n": 9335, "h": "40789c8b52c54b48", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] = None # 🆕 Custom Prompts 配置\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] # 🆕 Custom Prompts 配置\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 获取请求中实际设置的字段(区分\"未设置\"和\"设置为 null\")\n request_data = request.model_dump(exclude_unset=True)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置(只更新请求中出现的字段)\n config_data = existing_config.model_dump()\n for key in request_data:\n config_data[key] = request_data[key]\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n \"custom_prompts\": request.custom_prompts, # 🆕 Custom Prompts\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n custom_prompts=new_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )"}, "c1d833630f2e765decabcb83df1b1f47491e8944": {"t": 1785768252.4225612, "n": 9261, "h": "b80452ae2c527350", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] = None # 🆕 Custom Prompts 配置\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] # 🆕 Custom Prompts 配置\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 获取请求中实际设置的字段(区分\"未设置\"和\"设置为 null\")\n request_data = request.model_dump(exclude_unset=True)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置(只更新请求中出现的字段)\n config_data = existing_config.model_dump()\n for key in request_data:\n config_data[key] = request_data[key]\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n custom_prompts=new_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )"}, "b255fb341fb330eebdc42eab0e975ed66e1d3959": {"t": 1785768254.827215, "n": 9262, "h": "019d45bafb90344b", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] = None # 🆕 Custom Prompts 配置\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n custom_prompts: Optional[Dict[str, Any]] # 🆕 Custom Prompts 配置\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 获取请求中实际设置的字段(区分\"未设置\"和\"设置为 null\")\n request_data = request.model_dump(exclude_unset=True)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置(只更新请求中出现的字段)\n config_data = existing_config.model_dump()\n for key in request_data:\n config_data[key] = request_data[key]\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n custom_prompts=new_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n custom_prompts=tenant_config.custom_prompts, # 🆕 Custom Prompts(无需脱敏)\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n"}, "8eface3c76d5632a46a727daedd507dcd5eae23a": {"t": 1785768257.9939446, "n": 8881, "h": "d73c17093dfe1433", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 获取请求中实际设置的字段(区分\"未设置\"和\"设置为 null\")\n request_data = request.model_dump(exclude_unset=True)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置(只更新请求中出现的字段)\n config_data = existing_config.model_dump()\n for key in request_data:\n config_data[key] = request_data[key]\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n"}, "46936e5ff8efc9b652c4e9dab4cfe100ed193d3c": {"t": 1785768260.8536687, "n": 9273, "h": "390abf21e380fd73", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置\n config_data = existing_config.model_dump()\n if request.llm_config is not None:\n config_data[\"llm_config\"] = request.llm_config\n if request.embedding_config is not None:\n config_data[\"embedding_config\"] = request.embedding_config\n if request.rerank_config is not None:\n config_data[\"rerank_config\"] = request.rerank_config\n if request.ds_ocr_config is not None: # 🆕 DeepSeek-OCR\n config_data[\"ds_ocr_config\"] = request.ds_ocr_config\n if request.mineru_config is not None: # 🆕 MinerU\n config_data[\"mineru_config\"] = request.mineru_config\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n"}, "5fb7d5180868fb4bc7df409534b7b24793f3716d": {"t": 1785768262.9431427, "n": 10076, "h": "50f27a9ae18c87c2", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel,\n QuotaConfig\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n ds_ocr_config: Optional[Dict[str, Any]] = None # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] = None # 🆕 MinerU 配置\n quota: Optional[Dict[str, Any]] = None\n status: Optional[str] = None\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n ds_ocr_config: Optional[Dict[str, Any]] # 🆕 DeepSeek-OCR 配置\n mineru_config: Optional[Dict[str, Any]] # 🆕 MinerU 配置\n quota: Dict[str, Any]\n status: str\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key / API Token\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n # 脱敏 api_key(LLM, Embedding, Rerank, DeepSeek-OCR)\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n # 脱敏 api_token(MinerU)\n if \"api_token\" in masked:\n masked[\"api_token\"] = mask_api_key(masked[\"api_token\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n \"quota\": merged_config[\"quota\"],\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n quota=tenant_config.quota.model_dump(),\n status=tenant_config.status,\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置\n config_data = existing_config.model_dump()\n if request.llm_config is not None:\n config_data[\"llm_config\"] = request.llm_config\n if request.embedding_config is not None:\n config_data[\"embedding_config\"] = request.embedding_config\n if request.rerank_config is not None:\n config_data[\"rerank_config\"] = request.rerank_config\n if request.ds_ocr_config is not None: # 🆕 DeepSeek-OCR\n config_data[\"ds_ocr_config\"] = request.ds_ocr_config\n if request.mineru_config is not None: # 🆕 MinerU\n config_data[\"mineru_config\"] = request.mineru_config\n if request.quota is not None:\n config_data[\"quota\"] = QuotaConfig(**request.quota)\n if request.status is not None:\n config_data[\"status\"] = request.status\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"ds_ocr_config\": request.ds_ocr_config, # 🆕 DeepSeek-OCR\n \"mineru_config\": request.mineru_config, # 🆕 MinerU\n \"quota\": QuotaConfig(**request.quota) if request.quota else QuotaConfig(),\n \"status\": request.status or \"active\",\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n ds_ocr_config=mask_config(new_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(new_config.mineru_config), # 🆕 MinerU\n quota=new_config.quota.model_dump(),\n status=new_config.status,\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"ds_ocr\": mask_config(merged_config[\"ds_ocr\"]), # 🆕 DeepSeek-OCR\n \"mineru\": mask_config(merged_config[\"mineru\"]), # 🆕 MinerU\n \"quota\": merged_config[\"quota\"],\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n ds_ocr_config=mask_config(tenant_config.ds_ocr_config), # 🆕 DeepSeek-OCR\n mineru_config=mask_config(tenant_config.mineru_config), # 🆕 MinerU\n quota=tenant_config.quota.model_dump(),\n status=tenant_config.status,\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n"}, "2a5697eadab4dce635707a84db5a885316ff1f28": {"t": 1785768266.6588213, "n": 8480, "h": "ac372e8185505db8", "c": "\"\"\"\n租户配置管理 API\n\n支持租户级配置的 CRUD 操作和热重载。\n\"\"\"\n\nfrom fastapi import APIRouter, HTTPException, Header\nfrom pydantic import BaseModel, Field\nfrom typing import Optional, Dict, Any\nfrom datetime import datetime\nfrom src.tenant_config import (\n get_tenant_config_manager,\n TenantConfigModel,\n QuotaConfig\n)\nfrom src.logger import logger\n\n\nrouter = APIRouter(prefix=\"/tenants\", tags=[\"tenant-config\"])\n\n\nclass ConfigUpdateRequest(BaseModel):\n \"\"\"配置更新请求\"\"\"\n llm_config: Optional[Dict[str, Any]] = None\n embedding_config: Optional[Dict[str, Any]] = None\n rerank_config: Optional[Dict[str, Any]] = None\n quota: Optional[Dict[str, Any]] = None\n status: Optional[str] = None\n\n\nclass ConfigResponse(BaseModel):\n \"\"\"配置响应\"\"\"\n tenant_id: str\n llm_config: Optional[Dict[str, Any]]\n embedding_config: Optional[Dict[str, Any]]\n rerank_config: Optional[Dict[str, Any]]\n quota: Dict[str, Any]\n status: str\n created_at: Optional[datetime]\n updated_at: Optional[datetime]\n merged_config: Optional[Dict[str, Any]] = None\n\n\ndef mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"\n 脱敏 API Key\n\n Args:\n key: API Key\n\n Returns:\n str: 脱敏后的 Key(如 sk-***cdef)\n \"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"***\"\n return key[:3] + \"***\" + key[-4:]\n\n\ndef mask_config(config_dict: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:\n \"\"\"\n 脱敏配置中的 API Key\n\n Args:\n config_dict: 配置字典\n\n Returns:\n Dict: 脱敏后的配置\n \"\"\"\n if not config_dict:\n return None\n\n masked = config_dict.copy()\n if \"api_key\" in masked:\n masked[\"api_key\"] = mask_api_key(masked[\"api_key\"])\n return masked\n\n\n# TODO: 实现鉴权机制(P2 优先级)\n# async def verify_admin_token(authorization: str = Header(...)) -> str:\n# \"\"\"验证管理员 Token\"\"\"\n# if not authorization.startswith(\"Bearer \"):\n# raise HTTPException(status_code=401, detail=\"Invalid token format\")\n#\n# token = authorization.replace(\"Bearer \", \"\")\n# if not is_valid_admin_token(token):\n# raise HTTPException(status_code=401, detail=\"Unauthorized\")\n#\n# return token\n\n\n@router.get(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def get_tenant_config(tenant_id: str):\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 租户配置(合并全局配置)\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取租户配置\n tenant_config = manager.get(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"quota\": merged_config[\"quota\"],\n }\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n quota=tenant_config.quota.model_dump(),\n status=tenant_config.status,\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n\n\n@router.put(\"/{tenant_id}/config\", response_model=ConfigResponse)\nasync def update_tenant_config(\n tenant_id: str,\n request: ConfigUpdateRequest\n):\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n request: 配置更新请求\n\n Returns:\n ConfigResponse: 更新后的配置\n\n Raises:\n HTTPException 400: 配置验证失败\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 获取现有配置\n existing_config = manager.get(tenant_id)\n\n # 构建新配置\n if existing_config:\n # 更新现有配置\n config_data = existing_config.model_dump()\n if request.llm_config is not None:\n config_data[\"llm_config\"] = request.llm_config\n if request.embedding_config is not None:\n config_data[\"embedding_config\"] = request.embedding_config\n if request.rerank_config is not None:\n config_data[\"rerank_config\"] = request.rerank_config\n if request.quota is not None:\n config_data[\"quota\"] = QuotaConfig(**request.quota)\n if request.status is not None:\n config_data[\"status\"] = request.status\n else:\n # 创建新配置\n config_data = {\n \"tenant_id\": tenant_id,\n \"llm_config\": request.llm_config,\n \"embedding_config\": request.embedding_config,\n \"rerank_config\": request.rerank_config,\n \"quota\": QuotaConfig(**request.quota) if request.quota else QuotaConfig(),\n \"status\": request.status or \"active\",\n }\n\n try:\n # 验证并保存配置\n new_config = TenantConfigModel(**config_data)\n success = manager.set(tenant_id, new_config)\n\n if not success:\n raise HTTPException(status_code=500, detail=\"Failed to update config\")\n\n logger.info(f\"[{tenant_id}] Config updated via API\")\n\n # 返回更新后的配置\n return ConfigResponse(\n tenant_id=new_config.tenant_id,\n llm_config=mask_config(new_config.llm_config),\n embedding_config=mask_config(new_config.embedding_config),\n rerank_config=mask_config(new_config.rerank_config),\n quota=new_config.quota.model_dump(),\n status=new_config.status,\n created_at=new_config.created_at,\n updated_at=new_config.updated_at,\n merged_config=None\n )\n except Exception as e:\n logger.error(f\"[{tenant_id}] Config validation failed: {e}\")\n raise HTTPException(\n status_code=400,\n detail={\n \"message\": \"Invalid config\",\n \"errors\": [{\"field\": \"config\", \"message\": str(e)}]\n }\n )\n\n\n@router.delete(\"/{tenant_id}/config\")\nasync def delete_tenant_config(tenant_id: str):\n \"\"\"\n 删除租户配置(恢复使用全局配置)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n Dict: 删除结果\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n success = manager.delete(tenant_id)\n\n if not success:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found\",\n \"tenant_id\": tenant_id\n }\n )\n\n logger.info(f\"[{tenant_id}] Config deleted via API\")\n\n return {\n \"tenant_id\": tenant_id,\n \"status\": \"deleted\",\n \"message\": \"Tenant config deleted, now using global config\"\n }\n\n\n@router.post(\"/{tenant_id}/config/refresh\", response_model=ConfigResponse)\nasync def refresh_tenant_config(tenant_id: str):\n \"\"\"\n 刷新配置缓存(清除缓存并重新加载)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n ConfigResponse: 刷新后的配置\n\n Raises:\n HTTPException 404: 租户配置不存在\n \"\"\"\n manager = get_tenant_config_manager()\n\n # 强制刷新缓存\n tenant_config = manager.refresh(tenant_id)\n\n if not tenant_config:\n raise HTTPException(\n status_code=404,\n detail={\n \"message\": \"Tenant config not found after refresh\",\n \"tenant_id\": tenant_id,\n \"using_global_config\": True\n }\n )\n\n # 合并全局配置\n merged_config = manager.merge_with_global(tenant_config)\n\n # 脱敏 API Key\n masked_merged = {\n \"llm\": mask_config(merged_config[\"llm\"]),\n \"embedding\": mask_config(merged_config[\"embedding\"]),\n \"rerank\": mask_config(merged_config[\"rerank\"]),\n \"quota\": merged_config[\"quota\"],\n }\n\n logger.info(f\"[{tenant_id}] Config refreshed via API\")\n\n return ConfigResponse(\n tenant_id=tenant_config.tenant_id,\n llm_config=mask_config(tenant_config.llm_config),\n embedding_config=mask_config(tenant_config.embedding_config),\n rerank_config=mask_config(tenant_config.rerank_config),\n quota=tenant_config.quota.model_dump(),\n status=tenant_config.status,\n created_at=tenant_config.created_at,\n updated_at=tenant_config.updated_at,\n merged_config=masked_merged\n )\n"}}, "src/tenant_config.py": {"f7e2565a6faa09c79be1148c9087ba34841ae066": {"t": 1785746879.9982488, "n": 15066, "h": "5caa22d40226b8bf", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 🆕 Custom Prompts 配置(可选)\n custom_prompts: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LightRAG 自定义 Prompts 覆盖\",\n example={\n \"entity_extraction_system_prompt\": \"你的自定义 system prompt...\",\n \"entity_extraction_user_prompt\": \"你的自定义 user prompt...\",\n \"entity_continue_extraction_user_prompt\": \"你的自定义 continue prompt...\",\n \"entity_extraction_examples\": [\"示例1\", \"示例2\"],\n \"entity_types\": [\"product\", \"feature\", \"error_code\", \"configuration\"],\n \"strict_grounding\": \"true\",\n \"rag_response\": \"你的自定义 RAG 响应 prompt(可选)...\",\n \"naive_rag_response\": \"你的自定义 Naive RAG 响应 prompt(可选)...\"\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数,不直接控制 LightRAG 的 llm_model_max_async。\n LightRAG 的并发数会在 multi_tenant.py 中被强制设置为 RateLimiter 的实际值,确保一致性。\n \"\"\"\n base = {\n \"model\": config.llm.model,\n \"vlm_model\": config.llm.vlm_model,\n \"vlm_api_key\": config.llm.vlm_api_key,\n \"vlm_base_url\": config.llm.vlm_base_url,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async, # RateLimiter 的并发数(可选,未设置时自动计算)\n \"vlm_timeout\": config.llm.vlm_timeout,\n # 速率限制配置\n \"requests_per_minute\": config.llm.requests_per_minute,\n \"tokens_per_minute\": config.llm.tokens_per_minute,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n # 速率限制配置\n \"requests_per_minute\": config.embedding.requests_per_minute,\n \"tokens_per_minute\": config.embedding.tokens_per_minute,\n \"max_async\": config.embedding.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.embedding.timeout,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n # 速率限制配置\n \"requests_per_minute\": config.rerank.requests_per_minute,\n \"tokens_per_minute\": config.rerank.tokens_per_minute,\n \"max_async\": config.rerank.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.rerank.timeout,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置(包含速率限制)\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n # 速率限制配置\n \"requests_per_minute\": config.ds_ocr.requests_per_minute,\n \"tokens_per_minute\": config.ds_ocr.tokens_per_minute,\n \"max_async\": config.ds_ocr.max_async,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "3cbf5781291def54b1cbd920b1e0567171962663": {"t": 1785768249.291637, "n": 14915, "h": "349f8d09d65db640", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 🆕 Custom Prompts 配置(可选)\n custom_prompts: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LightRAG 自定义 Prompts 覆盖\",\n example={\n \"entity_extraction_system_prompt\": \"你的自定义 system prompt...\",\n \"entity_extraction_user_prompt\": \"你的自定义 user prompt...\",\n \"entity_continue_extraction_user_prompt\": \"你的自定义 continue prompt...\",\n \"entity_extraction_examples\": [\"示例1\", \"示例2\"],\n \"entity_types\": [\"product\", \"feature\", \"error_code\", \"configuration\"],\n \"strict_grounding\": \"true\",\n \"rag_response\": \"你的自定义 RAG 响应 prompt(可选)...\",\n \"naive_rag_response\": \"你的自定义 Naive RAG 响应 prompt(可选)...\"\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数,不直接控制 LightRAG 的 llm_model_max_async。\n LightRAG 的并发数会在 multi_tenant.py 中被强制设置为 RateLimiter 的实际值,确保一致性。\n \"\"\"\n base = {\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async, # RateLimiter 的并发数(可选,未设置时自动计算)\n \"vlm_timeout\": config.llm.vlm_timeout,\n # 速率限制配置\n \"requests_per_minute\": config.llm.requests_per_minute,\n \"tokens_per_minute\": config.llm.tokens_per_minute,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n # 速率限制配置\n \"requests_per_minute\": config.embedding.requests_per_minute,\n \"tokens_per_minute\": config.embedding.tokens_per_minute,\n \"max_async\": config.embedding.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.embedding.timeout,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n # 速率限制配置\n \"requests_per_minute\": config.rerank.requests_per_minute,\n \"tokens_per_minute\": config.rerank.tokens_per_minute,\n \"max_async\": config.rerank.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.rerank.timeout,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置(包含速率限制)\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n # 速率限制配置\n \"requests_per_minute\": config.ds_ocr.requests_per_minute,\n \"tokens_per_minute\": config.ds_ocr.tokens_per_minute,\n \"max_async\": config.ds_ocr.max_async,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "b255fb341fb330eebdc42eab0e975ed66e1d3959": {"t": 1785768251.1464717, "n": 14747, "h": "862f0b5aa418ac0c", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 🆕 Custom Prompts 配置(可选)\n custom_prompts: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LightRAG 自定义 Prompts 覆盖\",\n example={\n \"entity_extraction_system_prompt\": \"你的自定义 system prompt...\",\n \"entity_extraction_user_prompt\": \"你的自定义 user prompt...\",\n \"entity_continue_extraction_user_prompt\": \"你的自定义 continue prompt...\",\n \"entity_extraction_examples\": [\"示例1\", \"示例2\"],\n \"entity_types\": [\"product\", \"feature\", \"error_code\", \"configuration\"]\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数,不直接控制 LightRAG 的 llm_model_max_async。\n LightRAG 的并发数会在 multi_tenant.py 中被强制设置为 RateLimiter 的实际值,确保一致性。\n \"\"\"\n base = {\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async, # RateLimiter 的并发数(可选,未设置时自动计算)\n \"vlm_timeout\": config.llm.vlm_timeout,\n # 速率限制配置\n \"requests_per_minute\": config.llm.requests_per_minute,\n \"tokens_per_minute\": config.llm.tokens_per_minute,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n # 速率限制配置\n \"requests_per_minute\": config.embedding.requests_per_minute,\n \"tokens_per_minute\": config.embedding.tokens_per_minute,\n \"max_async\": config.embedding.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.embedding.timeout,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n # 速率限制配置\n \"requests_per_minute\": config.rerank.requests_per_minute,\n \"tokens_per_minute\": config.rerank.tokens_per_minute,\n \"max_async\": config.rerank.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.rerank.timeout,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置(包含速率限制)\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n # 速率限制配置\n \"requests_per_minute\": config.ds_ocr.requests_per_minute,\n \"tokens_per_minute\": config.ds_ocr.tokens_per_minute,\n \"max_async\": config.ds_ocr.max_async,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "44b4d11754f155a8b6fe1b912834db7b234d05cd": {"t": 1785768254.995072, "n": 14195, "h": "d63c0e0482258cb7", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数,不直接控制 LightRAG 的 llm_model_max_async。\n LightRAG 的并发数会在 multi_tenant.py 中被强制设置为 RateLimiter 的实际值,确保一致性。\n \"\"\"\n base = {\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async, # RateLimiter 的并发数(可选,未设置时自动计算)\n \"vlm_timeout\": config.llm.vlm_timeout,\n # 速率限制配置\n \"requests_per_minute\": config.llm.requests_per_minute,\n \"tokens_per_minute\": config.llm.tokens_per_minute,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n # 速率限制配置\n \"requests_per_minute\": config.embedding.requests_per_minute,\n \"tokens_per_minute\": config.embedding.tokens_per_minute,\n \"max_async\": config.embedding.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.embedding.timeout,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置(包含速率限制)\n\n Note: max_async 控制 RateLimiter 的并发数(可选,未设置时基于 TPM/RPM 自动计算)。\n \"\"\"\n base = {\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n # 速率限制配置\n \"requests_per_minute\": config.rerank.requests_per_minute,\n \"tokens_per_minute\": config.rerank.tokens_per_minute,\n \"max_async\": config.rerank.max_async, # RateLimiter 的并发数(可选)\n \"timeout\": config.rerank.timeout,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置(包含速率限制)\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n # 速率限制配置\n \"requests_per_minute\": config.ds_ocr.requests_per_minute,\n \"tokens_per_minute\": config.ds_ocr.tokens_per_minute,\n \"max_async\": config.ds_ocr.max_async,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "a9b9af8b10cf43d1f58d42ed63937f218aaac1f5": {"t": 1785768257.8570695, "n": 13792, "h": "7de4974f9808c8ba", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置(包含速率限制)\"\"\"\n base = {\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async,\n \"vlm_timeout\": config.llm.vlm_timeout,\n # 速率限制配置\n \"requests_per_minute\": config.llm.requests_per_minute,\n \"tokens_per_minute\": config.llm.tokens_per_minute,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置(包含速率限制)\"\"\"\n base = {\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n # 速率限制配置\n \"requests_per_minute\": config.embedding.requests_per_minute,\n \"tokens_per_minute\": config.embedding.tokens_per_minute,\n \"max_async\": config.embedding.max_async,\n \"timeout\": config.embedding.timeout,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置(包含速率限制)\"\"\"\n base = {\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n # 速率限制配置\n \"requests_per_minute\": config.rerank.requests_per_minute,\n \"tokens_per_minute\": config.rerank.tokens_per_minute,\n \"max_async\": config.rerank.max_async,\n \"timeout\": config.rerank.timeout,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置(包含速率限制)\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n # 速率限制配置\n \"requests_per_minute\": config.ds_ocr.requests_per_minute,\n \"tokens_per_minute\": config.ds_ocr.tokens_per_minute,\n \"max_async\": config.ds_ocr.max_async,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "77c6a3960983d15bd1b46d7ca6b53795021aef45": {"t": 1785768259.83592, "n": 12884, "h": "8b54a5d27f425d56", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置\"\"\"\n base = {\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async,\n \"vlm_timeout\": config.llm.vlm_timeout,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置\"\"\"\n base = {\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置\"\"\"\n base = {\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "46936e5ff8efc9b652c4e9dab4cfe100ed193d3c": {"t": 1785768262.4275737, "n": 13044, "h": "7c8e70869ce35324", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"provider\": \"openai\",\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"provider\": \"siliconflow\",\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置\"\"\"\n base = {\n \"provider\": config.llm.provider,\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async,\n \"vlm_timeout\": config.llm.vlm_timeout,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置\"\"\"\n base = {\n \"provider\": config.embedding.provider,\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置\"\"\"\n base = {\n \"provider\": config.rerank.provider,\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "5fb7d5180868fb4bc7df409534b7b24793f3716d": {"t": 1785768266.5838995, "n": 13849, "h": "feac34d4a416a4f4", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass QuotaConfig(BaseModel):\n \"\"\"配额配置\"\"\"\n daily_queries: int = Field(default=1000, description=\"每日查询配额\")\n storage_mb: int = Field(default=1000, description=\"存储配额(MB)\")\n max_file_size_mb: int = Field(default=100, description=\"单文件最大大小(MB)\")\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"provider\": \"openai\",\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"provider\": \"siliconflow\",\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # DeepSeek-OCR 配置(可选)\n ds_ocr_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"DeepSeek-OCR 配置覆盖\",\n example={\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.siliconflow.cn/v1\",\n \"model\": \"deepseek-ai/DeepSeek-OCR\",\n \"timeout\": 60\n }\n )\n\n # MinerU 配置(可选)\n mineru_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"MinerU 配置覆盖\",\n example={\n \"api_token\": \"your_token\",\n \"base_url\": \"https://mineru.net\",\n \"model_version\": \"vlm\",\n \"timeout\": 60\n }\n )\n\n # 配额配置\n quota: QuotaConfig = Field(\n default_factory=QuotaConfig,\n description=\"租户配额\"\n )\n\n # 状态\n status: str = Field(\n default=\"active\",\n description=\"租户状态\"\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"ds_ocr\": self._merge_ds_ocr_config(tenant_config),\n \"mineru\": self._merge_mineru_config(tenant_config),\n \"quota\": self._merge_quota_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置\"\"\"\n base = {\n \"provider\": config.llm.provider,\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async,\n \"vlm_timeout\": config.llm.vlm_timeout,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置\"\"\"\n base = {\n \"provider\": config.embedding.provider,\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置\"\"\"\n base = {\n \"provider\": config.rerank.provider,\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_quota_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并配额配置\"\"\"\n if tenant_config and tenant_config.quota:\n return tenant_config.quota.model_dump()\n else:\n return QuotaConfig().model_dump()\n\n def _merge_ds_ocr_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 DeepSeek-OCR 配置\"\"\"\n base = {\n \"api_key\": config.ds_ocr.api_key,\n \"base_url\": config.ds_ocr.base_url,\n \"model\": config.ds_ocr.model,\n \"timeout\": config.ds_ocr.timeout,\n \"max_tokens\": config.ds_ocr.max_tokens,\n \"dpi\": config.ds_ocr.dpi,\n \"default_mode\": config.ds_ocr.default_mode,\n \"fallback_enabled\": config.ds_ocr.fallback_enabled,\n \"fallback_mode\": config.ds_ocr.fallback_mode,\n \"min_output_threshold\": config.ds_ocr.min_output_threshold,\n }\n\n if tenant_config and tenant_config.ds_ocr_config:\n base.update(tenant_config.ds_ocr_config)\n\n return base\n\n def _merge_mineru_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 MinerU 配置\"\"\"\n base = {\n \"api_token\": os.getenv(\"MINERU_API_TOKEN\", \"\"),\n \"base_url\": os.getenv(\"MINERU_API_BASE_URL\", \"https://mineru.net\"),\n \"model_version\": os.getenv(\"MINERU_MODEL_VERSION\", \"vlm\"),\n \"timeout\": int(os.getenv(\"MINERU_HTTP_TIMEOUT\", \"60\")),\n \"max_concurrent_requests\": int(os.getenv(\"MINERU_MAX_CONCURRENT_REQUESTS\", \"5\")),\n \"requests_per_minute\": int(os.getenv(\"MINERU_REQUESTS_PER_MINUTE\", \"60\")),\n \"retry_max_attempts\": int(os.getenv(\"MINERU_RETRY_MAX_ATTEMPTS\", \"3\")),\n \"poll_timeout\": int(os.getenv(\"MINERU_POLL_TIMEOUT\", \"600\")),\n }\n\n if tenant_config and tenant_config.mineru_config:\n base.update(tenant_config.mineru_config)\n\n return base\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}, "2a5697eadab4dce635707a84db5a885316ff1f28": {"t": 1785768269.4476118, "n": 11349, "h": "6e09661e52e7afad", "c": "\"\"\"\n租户配置管理器\n\n支持本地文件存储和 Redis 存储的租户配置热重载,无需重启服务。\n\"\"\"\n\nimport os\nimport json\nimport redis\nfrom typing import Optional, Dict, Any\nfrom pydantic import BaseModel, Field\nfrom datetime import datetime\nfrom src.logger import logger\nfrom src.config import config\n\n\nclass QuotaConfig(BaseModel):\n \"\"\"配额配置\"\"\"\n daily_queries: int = Field(default=1000, description=\"每日查询配额\")\n storage_mb: int = Field(default=1000, description=\"存储配额(MB)\")\n max_file_size_mb: int = Field(default=100, description=\"单文件最大大小(MB)\")\n\n\nclass TenantConfigModel(BaseModel):\n \"\"\"租户配置模型\"\"\"\n\n tenant_id: str = Field(..., description=\"租户 ID\")\n\n # LLM 配置(可选,缺失字段使用全局配置)\n llm_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"LLM 配置覆盖\",\n example={\n \"provider\": \"openai\",\n \"model\": \"gpt-4\",\n \"api_key\": \"sk-xxx\",\n \"base_url\": \"https://api.openai.com/v1\"\n }\n )\n\n # Embedding 配置(可选)\n embedding_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Embedding 配置覆盖\",\n example={\n \"provider\": \"siliconflow\",\n \"model\": \"Qwen/Qwen3-Embedding-0.6B\",\n \"api_key\": \"sk-yyy\",\n \"dim\": 1024\n }\n )\n\n # Rerank 配置(可选)\n rerank_config: Optional[Dict[str, Any]] = Field(\n default=None,\n description=\"Rerank 配置覆盖\"\n )\n\n # 配额配置\n quota: QuotaConfig = Field(\n default_factory=QuotaConfig,\n description=\"租户配额\"\n )\n\n # 状态\n status: str = Field(\n default=\"active\",\n description=\"租户状态\"\n )\n\n # 元数据\n created_at: Optional[datetime] = Field(default=None)\n updated_at: Optional[datetime] = Field(default=None)\n\n\nclass TenantConfigManager:\n \"\"\"\n 租户配置管理器\n\n 支持两种存储方式:\n - local: 本地 JSON 文件存储(开发/测试环境)\n - redis: Redis 存储(生产环境)\n\n 注意:租户配置不会降级到全局配置,不存在时返回 None\n \"\"\"\n\n def __init__(\n self,\n storage_type: str = \"local\",\n local_storage_dir: str = \"./tenant_configs\",\n redis_uri: str = None\n ):\n \"\"\"\n 初始化租户配置管理器\n\n Args:\n storage_type: 存储类型 (\"local\" 或 \"redis\")\n local_storage_dir: 本地存储目录(storage_type=\"local\" 时使用)\n redis_uri: Redis 连接 URI(storage_type=\"redis\" 时使用,默认从全局配置读取)\n \"\"\"\n self.storage_type = storage_type\n\n if storage_type == \"local\":\n # 本地文件存储\n self.local_storage_dir = local_storage_dir\n import os\n os.makedirs(self.local_storage_dir, exist_ok=True)\n logger.info(f\"TenantConfigManager initialized (storage=local, dir={self.local_storage_dir})\")\n\n elif storage_type == \"redis\":\n # Redis 存储\n self.redis_uri = redis_uri or config.storage.redis_uri\n try:\n self.redis_client = redis.from_url(self.redis_uri, decode_responses=True)\n self.redis_client.ping()\n logger.info(f\"TenantConfigManager initialized (storage=redis, uri={self.redis_uri})\")\n except Exception as e:\n raise ConnectionError(\n f\"Failed to connect to Redis ({self.redis_uri}): {e}. \"\n f\"For local development, use storage_type='local' instead.\"\n )\n else:\n raise ValueError(f\"Invalid storage_type: {storage_type}. Must be 'local' or 'redis'.\")\n\n def get(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 获取租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 租户配置(如果存在)\n None: 租户配置不存在\n\n 注意:不会降级到全局配置,调用方需自行处理 None 情况\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n with open(config_file, \"r\", encoding=\"utf-8\") as f:\n config_dict = json.load(f)\n logger.debug(f\"[{tenant_id}] Loaded config from local file\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n config_json = self.redis_client.get(f\"tenant:config:{tenant_id}\")\n if config_json:\n config_dict = json.loads(config_json)\n logger.debug(f\"[{tenant_id}] Loaded config from Redis\")\n return TenantConfigModel(**config_dict)\n else:\n logger.debug(f\"[{tenant_id}] No tenant config found\")\n return None\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to load config: {e}\")\n return None\n\n def set(self, tenant_id: str, config: TenantConfigModel) -> bool:\n \"\"\"\n 更新租户配置\n\n Args:\n tenant_id: 租户 ID\n config: 租户配置\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n # 更新时间戳\n config.tenant_id = tenant_id\n config.updated_at = datetime.utcnow()\n if not config.created_at:\n config.created_at = config.updated_at\n\n # 序列化为 JSON\n config_json = config.model_dump_json(indent=2)\n\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n with open(config_file, \"w\", encoding=\"utf-8\") as f:\n f.write(config_json)\n logger.info(f\"[{tenant_id}] Config saved to local file\")\n return True\n\n elif self.storage_type == \"redis\":\n # Redis 存储(永久保存,无 TTL)\n self.redis_client.set(\n f\"tenant:config:{tenant_id}\",\n config_json\n )\n logger.info(f\"[{tenant_id}] Config saved to Redis (persistent)\")\n return True\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to update config: {e}\")\n return False\n\n def delete(self, tenant_id: str) -> bool:\n \"\"\"\n 删除租户配置\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n bool: 是否成功\n \"\"\"\n try:\n if self.storage_type == \"local\":\n # 本地文件存储\n config_file = f\"{self.local_storage_dir}/{tenant_id}.json\"\n if os.path.exists(config_file):\n os.remove(config_file)\n logger.info(f\"[{tenant_id}] Config file deleted\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config file not found\")\n return False\n\n elif self.storage_type == \"redis\":\n # Redis 存储\n deleted_count = self.redis_client.delete(f\"tenant:config:{tenant_id}\")\n if deleted_count > 0:\n logger.info(f\"[{tenant_id}] Config deleted from Redis\")\n return True\n else:\n logger.warning(f\"[{tenant_id}] Config not found in Redis\")\n return False\n\n except Exception as e:\n logger.error(f\"[{tenant_id}] Failed to delete config: {e}\")\n return False\n\n def refresh(self, tenant_id: str) -> Optional[TenantConfigModel]:\n \"\"\"\n 强制刷新配置(对于 Redis 重新读取,对于 local 重新加载文件)\n\n Args:\n tenant_id: 租户 ID\n\n Returns:\n TenantConfigModel: 刷新后的配置(如果存在)\n \"\"\"\n logger.info(f\"[{tenant_id}] Forcing config refresh\")\n return self.get(tenant_id)\n\n def merge_with_global(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"\n 将租户配置与全局配置合并\n\n Args:\n tenant_config: 租户配置(可选)\n\n Returns:\n Dict: 合并后的最终配置\n \"\"\"\n merged = {\n \"llm\": self._merge_llm_config(tenant_config),\n \"embedding\": self._merge_embedding_config(tenant_config),\n \"rerank\": self._merge_rerank_config(tenant_config),\n \"quota\": self._merge_quota_config(tenant_config),\n }\n return merged\n\n def _merge_llm_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 LLM 配置\"\"\"\n base = {\n \"provider\": config.llm.provider,\n \"model\": config.llm.model,\n \"api_key\": config.llm.api_key,\n \"base_url\": config.llm.base_url,\n \"timeout\": config.llm.timeout,\n \"max_async\": config.llm.max_async,\n \"vlm_timeout\": config.llm.vlm_timeout,\n }\n\n if tenant_config and tenant_config.llm_config:\n base.update(tenant_config.llm_config)\n\n return base\n\n def _merge_embedding_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Embedding 配置\"\"\"\n base = {\n \"provider\": config.embedding.provider,\n \"model\": config.embedding.model,\n \"api_key\": config.embedding.api_key,\n \"base_url\": config.embedding.base_url,\n \"dim\": config.embedding.dim,\n }\n\n if tenant_config and tenant_config.embedding_config:\n base.update(tenant_config.embedding_config)\n\n return base\n\n def _merge_rerank_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并 Rerank 配置\"\"\"\n base = {\n \"provider\": config.rerank.provider,\n \"model\": config.rerank.model,\n \"api_key\": config.rerank.api_key,\n \"base_url\": config.rerank.base_url,\n }\n\n if tenant_config and tenant_config.rerank_config:\n base.update(tenant_config.rerank_config)\n\n return base\n\n def _merge_quota_config(self, tenant_config: Optional[TenantConfigModel]) -> Dict[str, Any]:\n \"\"\"合并配额配置\"\"\"\n if tenant_config and tenant_config.quota:\n return tenant_config.quota.model_dump()\n else:\n return QuotaConfig().model_dump()\n\n\n# 全局单例管理器\n_tenant_config_manager: Optional[TenantConfigManager] = None\n\n\ndef get_tenant_config_manager() -> TenantConfigManager:\n \"\"\"\n 获取全局租户配置管理器(单例)\n\n 存储类型通过环境变量 TENANT_CONFIG_STORAGE 控制:\n - \"local\": 本地文件存储(默认,适合开发/测试)\n - \"redis\": Redis 存储(适合生产环境)\n \"\"\"\n global _tenant_config_manager\n if _tenant_config_manager is None:\n # 从环境变量读取存储类型\n storage_type = os.getenv(\"TENANT_CONFIG_STORAGE\", \"local\")\n storage_type = storage_type.lower()\n\n if storage_type == \"local\":\n local_dir = os.getenv(\"TENANT_CONFIG_DIR\", \"./tenant_configs\")\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"local\",\n local_storage_dir=local_dir\n )\n elif storage_type == \"redis\":\n _tenant_config_manager = TenantConfigManager(\n storage_type=\"redis\"\n )\n else:\n # 默认使用本地存储\n logger.warning(f\"Invalid TENANT_CONFIG_STORAGE={storage_type}, using 'local'\")\n _tenant_config_manager = TenantConfigManager(storage_type=\"local\")\n\n return _tenant_config_manager\n"}}}} |