Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/bukely_rag-api.json
T

1 line
293 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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[![CI](https://github.com/BukeLy/rag-api/actions/workflows/ci-basic.yml/badge.svg)](https://github.com/BukeLy/rag-api/actions/workflows/ci-basic.yml)\n[![Python](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/)\n[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-00C7B7.svg)](https://fastapi.tiangolo.com/)\n[![LightRAG](https://img.shields.io/badge/LightRAG-1.4+-orange.svg)](https://github.com/HKUDS/LightRAG)\n[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)\n[![License](https://img.shields.io/badge/License-MIT-green.svg)](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[![CI](https://github.com/BukeLy/rag-api/actions/workflows/ci-basic.yml/badge.svg)](https://github.com/BukeLy/rag-api/actions/workflows/ci-basic.yml)\n[![Python](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/)\n[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-00C7B7.svg)](https://fastapi.tiangolo.com/)\n[![LightRAG](https://img.shields.io/badge/LightRAG-1.4+-orange.svg)](https://github.com/HKUDS/LightRAG)\n[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)\n[![License](https://img.shields.io/badge/License-MIT-green.svg)](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"}}}}