Spaces:
Sleeping
Sleeping
Sun Jun 8 15:02:12 CST 2025
Browse files- .gitignore +2 -1
- Dockerfile +1 -1
- app/__init__.py +0 -0
- app/crud.py +114 -0
- app/database.py +75 -0
- key_selector.py +1 -1
- app.py → main.py +7 -1
- memory-bank/activeContext.md +22 -11
- memory-bank/progress.md +20 -13
- memory-bank/systemPatterns.md +16 -9
- memory-bank/techContext.md +10 -4
- requirements.txt +2 -0
- tests/test_crud.py +152 -0
.gitignore
CHANGED
@@ -1,3 +1,4 @@
|
|
1 |
**/__pycache__/
|
2 |
README.md
|
3 |
-
.env
|
|
|
|
1 |
**/__pycache__/
|
2 |
README.md
|
3 |
+
.env
|
4 |
+
api_proxy.db
|
Dockerfile
CHANGED
@@ -13,4 +13,4 @@ COPY --chown=user ./requirements.txt requirements.txt
|
|
13 |
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
14 |
|
15 |
COPY --chown=user . /app
|
16 |
-
CMD ["uvicorn", "
|
|
|
13 |
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
14 |
|
15 |
COPY --chown=user . /app
|
16 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/__init__.py
ADDED
File without changes
|
app/crud.py
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.orm import Session
|
2 |
+
from .database import Base, KeyCategory, APIKey # Import models directly from database module
|
3 |
+
# from . import schemas # Schemas will be created later
|
4 |
+
|
5 |
+
# --- CRUD for KeyCategory ---
|
6 |
+
|
7 |
+
def create_key_category(db: Session, name: str, type: str, tags: list = None, metadata: dict = None):
|
8 |
+
db_category = KeyCategory(name=name, type=type, tags=tags, metadata_=metadata)
|
9 |
+
db.add(db_category)
|
10 |
+
db.commit()
|
11 |
+
db.refresh(db_category)
|
12 |
+
return db_category
|
13 |
+
|
14 |
+
def get_key_category(db: Session, category_id: int):
|
15 |
+
return db.query(KeyCategory).filter(KeyCategory.id == category_id).first()
|
16 |
+
|
17 |
+
def get_key_category_by_name(db: Session, name: str):
|
18 |
+
return db.query(KeyCategory).filter(KeyCategory.name == name).first()
|
19 |
+
|
20 |
+
def get_key_categories(db: Session, skip: int = 0, limit: int = 100):
|
21 |
+
return db.query(KeyCategory).offset(skip).limit(limit).all()
|
22 |
+
|
23 |
+
def update_key_category(db: Session, category_id: int, name: str = None, type: str = None, tags: list = None, metadata: dict = None):
|
24 |
+
db_category = get_key_category(db, category_id)
|
25 |
+
if db_category:
|
26 |
+
if name is not None:
|
27 |
+
db_category.name = name
|
28 |
+
if type is not None:
|
29 |
+
db_category.type = type
|
30 |
+
if tags is not None:
|
31 |
+
db_category.tags = tags
|
32 |
+
if metadata is not None:
|
33 |
+
db_category.metadata_ = metadata
|
34 |
+
db.commit()
|
35 |
+
db.refresh(db_category)
|
36 |
+
return db_category
|
37 |
+
|
38 |
+
def delete_key_category(db: Session, category_id: int):
|
39 |
+
db_category = get_key_category(db, category_id)
|
40 |
+
if db_category:
|
41 |
+
db.delete(db_category)
|
42 |
+
db.commit()
|
43 |
+
return db_category
|
44 |
+
|
45 |
+
# --- CRUD for APIKey ---
|
46 |
+
|
47 |
+
def create_api_key(db: Session, value: str, category_id: int, status: str = "active", metadata: dict = None):
|
48 |
+
db_key = APIKey(value=value, category_id=category_id, status=status, metadata_=metadata)
|
49 |
+
db.add(db_key)
|
50 |
+
db.commit()
|
51 |
+
db.refresh(db_key)
|
52 |
+
return db_key
|
53 |
+
|
54 |
+
def get_api_key(db: Session, key_id: int):
|
55 |
+
return db.query(APIKey).filter(APIKey.id == key_id).first()
|
56 |
+
|
57 |
+
def get_api_keys(db: Session, category_id: int = None, status: str = None, skip: int = 0, limit: int = 100):
|
58 |
+
query = db.query(APIKey)
|
59 |
+
if category_id is not None:
|
60 |
+
query = query.filter(APIKey.category_id == category_id)
|
61 |
+
if status is not None:
|
62 |
+
query = query.filter(APIKey.status == status)
|
63 |
+
return query.offset(skip).limit(limit).all()
|
64 |
+
|
65 |
+
def update_api_key(db: Session, key_id: int, value: str = None, category_id: int = None, status: str = None, metadata: dict = None):
|
66 |
+
db_key = get_api_key(db, key_id)
|
67 |
+
if db_key:
|
68 |
+
if value is not None:
|
69 |
+
db_key.value = value
|
70 |
+
if category_id is not None:
|
71 |
+
db_key.category_id = category_id
|
72 |
+
if status is not None:
|
73 |
+
db_key.status = status
|
74 |
+
if metadata is not None:
|
75 |
+
db_key.metadata_ = metadata
|
76 |
+
db.commit()
|
77 |
+
db.refresh(db_key)
|
78 |
+
return db_key
|
79 |
+
|
80 |
+
def delete_api_key(db: Session, key_id: int):
|
81 |
+
db_key = get_api_key(db, key_id)
|
82 |
+
if db_key:
|
83 |
+
db.delete(db_key)
|
84 |
+
db.commit()
|
85 |
+
return db_key
|
86 |
+
|
87 |
+
# --- Key Selection Logic (Basic Placeholder) ---
|
88 |
+
# This will be expanded later based on selection strategy (round-robin, etc.)
|
89 |
+
|
90 |
+
def get_available_keys_for_category(db: Session, category_id: int):
|
91 |
+
"""Get all active keys for a given category."""
|
92 |
+
return db.query(APIKey).filter(
|
93 |
+
APIKey.category_id == category_id,
|
94 |
+
APIKey.status == "active"
|
95 |
+
).all()
|
96 |
+
|
97 |
+
# Placeholder for selection strategy - will need to implement round-robin, etc.
|
98 |
+
def select_key_from_pool(db: Session, category_id: int):
|
99 |
+
"""Select one key from the available pool for a category."""
|
100 |
+
available_keys = get_available_keys_for_category(db, category_id)
|
101 |
+
if not available_keys:
|
102 |
+
return None # No keys available
|
103 |
+
|
104 |
+
# Basic selection: just return the first active key for now
|
105 |
+
# TODO: Implement round-robin or other selection strategy
|
106 |
+
selected_key = available_keys[0]
|
107 |
+
|
108 |
+
# Optional: Update usage stats (e.g., increment usage_count, update last_used)
|
109 |
+
# selected_key.usage_count += 1
|
110 |
+
# selected_key.last_used = func.now()
|
111 |
+
# db.commit()
|
112 |
+
# db.refresh(selected_key)
|
113 |
+
|
114 |
+
return selected_key
|
app/database.py
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, ForeignKey, func
|
3 |
+
from sqlalchemy.ext.declarative import declarative_base
|
4 |
+
from sqlalchemy.orm import sessionmaker, relationship
|
5 |
+
from sqlalchemy.types import TypeDecorator, TEXT
|
6 |
+
|
7 |
+
# Custom type for JSON storage
|
8 |
+
class JSONText(TypeDecorator):
|
9 |
+
impl = TEXT
|
10 |
+
|
11 |
+
def process_bind_param(self, value, dialect):
|
12 |
+
if value is not None:
|
13 |
+
return json.dumps(value)
|
14 |
+
return None
|
15 |
+
|
16 |
+
def process_result_value(self, value, dialect):
|
17 |
+
if value is not None:
|
18 |
+
return json.loads(value)
|
19 |
+
return None
|
20 |
+
|
21 |
+
# Database connection URL (using SQLite)
|
22 |
+
DATABASE_URL = "sqlite:///./api_proxy.db"
|
23 |
+
|
24 |
+
# Create SQLAlchemy engine
|
25 |
+
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
26 |
+
|
27 |
+
# Create a configured "Session" class
|
28 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
29 |
+
|
30 |
+
# Base class for declarative models
|
31 |
+
Base = declarative_base()
|
32 |
+
|
33 |
+
# Define the KeyCategory model
|
34 |
+
class KeyCategory(Base):
|
35 |
+
__tablename__ = "key_categories"
|
36 |
+
|
37 |
+
id = Column(Integer, primary_key=True, index=True)
|
38 |
+
name = Column(String, unique=True, index=True, nullable=False)
|
39 |
+
type = Column(String, nullable=False)
|
40 |
+
tags = Column(JSONText) # Stored as JSON string
|
41 |
+
metadata_ = Column(JSONText, name="metadata") # Use metadata_ to avoid conflict with SQLAlchemy metadata
|
42 |
+
|
43 |
+
api_keys = relationship("APIKey", back_populates="category")
|
44 |
+
|
45 |
+
created_at = Column(DateTime, server_default=func.now())
|
46 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
47 |
+
|
48 |
+
# Define the APIKey model
|
49 |
+
class APIKey(Base):
|
50 |
+
__tablename__ = "api_keys"
|
51 |
+
|
52 |
+
id = Column(Integer, primary_key=True, index=True)
|
53 |
+
value = Column(String, nullable=False)
|
54 |
+
category_id = Column(Integer, ForeignKey("key_categories.id"), nullable=False)
|
55 |
+
status = Column(String, default="active", nullable=False) # 'active', 'inactive'
|
56 |
+
usage_count = Column(Integer, default=0, nullable=False)
|
57 |
+
last_used = Column(DateTime)
|
58 |
+
metadata_ = Column(JSONText, name="metadata") # Use metadata_
|
59 |
+
|
60 |
+
category = relationship("KeyCategory", back_populates="api_keys")
|
61 |
+
|
62 |
+
created_at = Column(DateTime, server_default=func.now())
|
63 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
64 |
+
|
65 |
+
# Function to create database tables
|
66 |
+
def create_db_tables():
|
67 |
+
Base.metadata.create_all(bind=engine)
|
68 |
+
|
69 |
+
# Dependency to get DB session
|
70 |
+
def get_db():
|
71 |
+
db = SessionLocal()
|
72 |
+
try:
|
73 |
+
yield db
|
74 |
+
finally:
|
75 |
+
db.close()
|
key_selector.py
CHANGED
@@ -6,7 +6,7 @@ class KeySelector():
|
|
6 |
def __init__(self):
|
7 |
pass
|
8 |
|
9 |
-
def
|
10 |
api_key_info = {
|
11 |
"key_at": "at_headers",
|
12 |
"key_name": "X_Goog_Api_Key",
|
|
|
6 |
def __init__(self):
|
7 |
pass
|
8 |
|
9 |
+
def get_api_key_info(self):
|
10 |
api_key_info = {
|
11 |
"key_at": "at_headers",
|
12 |
"key_name": "X_Goog_Api_Key",
|
app.py → main.py
RENAMED
@@ -36,7 +36,7 @@ async def proxy(request: Request, path: str):
|
|
36 |
if k.lower() not in ["host", "content-length"]}
|
37 |
|
38 |
key_selector = KeySelector()
|
39 |
-
headers["X-Goog-Api-Key"] = key_selector.
|
40 |
|
41 |
try:
|
42 |
# 关键修复:禁用KeepAlive防止连接冲突
|
@@ -133,3 +133,9 @@ async def proxy(request: Request, path: str):
|
|
133 |
except Exception as e:
|
134 |
logger.exception("Unexpected proxy error")
|
135 |
raise HTTPException(500, f"内部服务器错误: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
if k.lower() not in ["host", "content-length"]}
|
37 |
|
38 |
key_selector = KeySelector()
|
39 |
+
headers["X-Goog-Api-Key"] = key_selector.get_api_key_info()['key_value'] # 从数据库获取API密钥
|
40 |
|
41 |
try:
|
42 |
# 关键修复:禁用KeepAlive防止连接冲突
|
|
|
133 |
except Exception as e:
|
134 |
logger.exception("Unexpected proxy error")
|
135 |
raise HTTPException(500, f"内部服务器错误: {str(e)}")
|
136 |
+
|
137 |
+
if __name__ == "__main__":
|
138 |
+
# In a real application, you would typically run uvicorn here:
|
139 |
+
# import uvicorn
|
140 |
+
# uvicorn.run(app, host="0.0.0.0", port=8000)
|
141 |
+
pass # Placeholder to fix indentation error
|
memory-bank/activeContext.md
CHANGED
@@ -1,25 +1,36 @@
|
|
1 |
# 当前背景 (Active Context)
|
2 |
|
3 |
## 当前工作重点
|
4 |
-
|
5 |
|
6 |
## 最近的变更
|
7 |
-
-
|
8 |
-
-
|
9 |
-
- 创建了 `
|
10 |
-
-
|
|
|
|
|
|
|
|
|
11 |
|
12 |
## 下一步计划
|
13 |
-
-
|
14 |
-
-
|
15 |
-
-
|
16 |
-
-
|
|
|
|
|
17 |
|
18 |
## 重要的模式和偏好
|
19 |
- 优先使用异步编程。
|
20 |
- 保持代码简洁和模块化。
|
21 |
- 遵循 FastAPI 的最佳实践。
|
|
|
|
|
22 |
|
23 |
## 学习和项目洞察
|
24 |
-
-
|
25 |
-
-
|
|
|
|
|
|
|
|
1 |
# 当前背景 (Active Context)
|
2 |
|
3 |
## 当前工作重点
|
4 |
+
实现基于数据库的 API Key 管理和代理规则配置,并集成到核心代理转发逻辑中。
|
5 |
|
6 |
## 最近的变更
|
7 |
+
- 讨论并设计了使用 SQLite 数据库存储 Key 类别、API Key 实例和代理规则的 Schema (三表设计: `key_categories`, `api_keys`, `proxy_rules`)。
|
8 |
+
- 安装了 SQLAlchemy 和 aiosqlite 库。
|
9 |
+
- 创建了 `app/database.py` 文件,定义了 Key 类别 (`KeyCategory`) 和 API Key 实例 (`APIKey`) 的 SQLAlchemy 模型。
|
10 |
+
- 使用 `sqlite3` 命令成功创建了数据库表 (`api_proxy.db`)。
|
11 |
+
- 创建了 `app/crud.py` 文件,实现了 Key 类别和 API Key 实例的基本 CRUD (创建、读取、更新、删除) 操作函数。
|
12 |
+
- 创建了 `tests/test_crud.py` 文件,为 CRUD 函数编写了单元测试。
|
13 |
+
- 成功运行了所有单元测试 (14 个通过)。
|
14 |
+
- 解决了 Python 包导入 (`ModuleNotFoundError`, `NameError`) 和文件创建 (`__init__.py` 未成功创建) 等环境和代码问题。
|
15 |
|
16 |
## 下一步计划
|
17 |
+
- 实现 Key 管理相关的 API 路由 (`/api/keys/...`),使用 `app/crud.py` 中的函数与数据库交互。
|
18 |
+
- 完善 Key 选择逻辑 (`app/crud.py` 中的 `select_key_from_pool`),实现 Key 池的轮询或其他策略。
|
19 |
+
- 设计并实现代理规则的数据库存储和加载逻辑(`proxy_rules` 表的 CRUD 和查询)。
|
20 |
+
- 将 Key 选择和代理规则查找逻辑集成到核心代理转发路由 (`app/routers/proxy.py`) 中。
|
21 |
+
- 实现前端/静态文件路由 (`app/routers/frontend.py`) (如果需要)。
|
22 |
+
- 编写其他功能的单元测试和集成测试。
|
23 |
|
24 |
## 重要的模式和偏好
|
25 |
- 优先使用异步编程。
|
26 |
- 保持代码简洁和模块化。
|
27 |
- 遵循 FastAPI 的最佳实践。
|
28 |
+
- 使用数据库进行配置管理以提高灵活性。
|
29 |
+
- 通过单元测试验证核心逻辑。
|
30 |
|
31 |
## 学习和项目洞察
|
32 |
+
- 确认了使用 SQLite 和 SQLAlchemy 进行数据库操作的可行性。
|
33 |
+
- 解决了在当前环境中进行 Python 包导入和文件创建时遇到的具体问题。
|
34 |
+
- 明确了 Key 的应用方式应与代理规则关联,而非 Key 本身。
|
35 |
+
- 数据库三表设计 (`key_categories`, `api_keys`, `proxy_rules`) 能够更好地组织 Key 和规则信息。
|
36 |
+
</textarea>
|
memory-bank/progress.md
CHANGED
@@ -1,24 +1,29 @@
|
|
1 |
# 项目进度 (Progress)
|
2 |
|
3 |
## 当前状态
|
4 |
-
|
5 |
|
6 |
## 已完成的工作
|
7 |
-
- 创建了 Memory Bank
|
8 |
-
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
|
|
|
|
|
|
14 |
|
15 |
## 待完成的工作
|
16 |
-
- 实现
|
17 |
-
-
|
18 |
-
-
|
|
|
|
|
19 |
- 添加基本的日志记录功能。
|
20 |
- 根据需求实现请求/响应转换功能。
|
21 |
-
-
|
22 |
- 完善 Dockerfile。
|
23 |
- 编写详细的 README.md 文档。
|
24 |
|
@@ -28,4 +33,6 @@
|
|
28 |
## 项目决策演变
|
29 |
- 决定使用 FastAPI 作为后端框架,因为它提供了高性能和易于使用的异步支持。
|
30 |
- 决定使用 `httpx` 作为 HTTP 客户端,因为它支持异步请求。
|
31 |
-
-
|
|
|
|
|
|
1 |
# 项目进度 (Progress)
|
2 |
|
3 |
## 当前状态
|
4 |
+
数据库已设置,Key 类别和 API Key 实例的基本 CRUD 操作已实现并通过单元测试。项目记忆库已更新。
|
5 |
|
6 |
## 已完成的工作
|
7 |
+
- 创建了 Memory Bank 目录并初始化了核心记忆文件。
|
8 |
+
- 讨论并设计了基于 SQLite 的 Key 管理和代理规则数据库 Schema (三表设计)。
|
9 |
+
- 安装了必要的 Python 依赖 (SQLAlchemy, aiosqlite)。
|
10 |
+
- 创建了 `app/database.py` 文件,定义了 SQLAlchemy 模型和数据库连接。
|
11 |
+
- 使用 `sqlite3` 命令成功创建了数据库表 (`api_proxy.db`)。
|
12 |
+
- 创建了 `app/crud.py` 文件,实现了 Key 类别和 API Key 实例的 CRUD 函数。
|
13 |
+
- 创建了 `tests/test_crud.py` 文件,为 CRUD 函数编写了单元测试。
|
14 |
+
- 成功运行了所有单元测试 (14 个通过)。
|
15 |
+
- 解决了环境配置和 Python 导入问题 (`__init__.py` 创建、导入路径修正)。
|
16 |
+
- 更新了 Memory Bank 文件 (`systemPatterns.md`, `techContext.md`, `activeContext.md`, `progress.md`)。
|
17 |
|
18 |
## 待完成的工作
|
19 |
+
- 实现 Key 管理相关的 API 路由 (`/api/keys/...`)。
|
20 |
+
- 完善 Key 选择逻辑 (`app/crud.py` 中的 `select_key_from_pool`),实现 Key 池的轮询或其他策略。
|
21 |
+
- 设计并实现代理规则的数据库存储和加载逻辑(`proxy_rules` 表的 CRUD 和查询)。
|
22 |
+
- 将 Key 选择和代理规则查找逻辑集成到核心代理转发路由 (`app/routers/proxy.py`) 中。
|
23 |
+
- 实现前端/静态文件路由 (`app/routers/frontend.py`) (如果需要)。
|
24 |
- 添加基本的日志记录功能。
|
25 |
- 根据需求实现请求/响应转换功能。
|
26 |
+
- 编写剩余功能的单元测试和集成测试。
|
27 |
- 完善 Dockerfile。
|
28 |
- 编写详细的 README.md 文档。
|
29 |
|
|
|
33 |
## 项目决策演变
|
34 |
- 决定使用 FastAPI 作为后端框架,因为它提供了高性能和易于使用的异步支持。
|
35 |
- 决定使用 `httpx` 作为 HTTP 客户端,因为它支持异步请求。
|
36 |
+
- **重要决策**: 决定使用 SQLite 数据库 (配合 SQLAlchemy 和 aiosqlite) 来管理 API Key 和代理规则,取代了最初使用静态配置文件的想法,以支持更灵活的动态管理和 Key 池化。
|
37 |
+
- 数据库 Schema 经过讨论,从单表设计演变为三表设计 (`key_categories`, `api_keys`, `proxy_rules`),以更好地组织数据和处理 Key 应用方式与规则关联的需求。
|
38 |
+
</textarea>
|
memory-bank/systemPatterns.md
CHANGED
@@ -1,32 +1,39 @@
|
|
1 |
# 系统模式 (System Patterns)
|
2 |
|
3 |
## 架构概述
|
4 |
-
本项目将采用基于 FastAPI 的微服务架构。核心是一个 FastAPI
|
5 |
|
6 |
```mermaid
|
7 |
graph LR
|
8 |
Client --> Proxy[FastAPI Proxy]
|
|
|
9 |
Proxy --> BackendA[Backend Service A]
|
10 |
Proxy --> BackendB[Backend Service B]
|
11 |
Proxy --> BackendC[Backend Service C]
|
|
|
12 |
```
|
13 |
|
14 |
## 设计模式
|
15 |
- **API Gateway Pattern**: FastAPI 应用充当 API 网关,作为客户端访问后端服务的单一入口点。
|
16 |
-
- **
|
|
|
17 |
- **Middleware Pattern**: 利用 FastAPI 的中间件功能处理请求和响应的通用逻辑(如日志、认证等)。
|
18 |
|
19 |
## 关键组件
|
20 |
-
- **FastAPI Application**:
|
21 |
-
- **
|
|
|
|
|
|
|
22 |
- **HTTP Client**: 用于向后端服务发起请求(例如使用 `httpx` 库)。
|
23 |
- **Request/Response Transformer (Optional)**: 根据配置修改请求和响应。
|
24 |
|
25 |
## 数据流
|
26 |
1. 客户端发起请求到 FastAPI Proxy。
|
27 |
2. FastAPI Proxy 接收请求。
|
28 |
-
3.
|
29 |
-
4.
|
30 |
-
5.
|
31 |
-
6.
|
32 |
-
7.
|
|
|
|
1 |
# 系统模式 (System Patterns)
|
2 |
|
3 |
## 架构概述
|
4 |
+
本项目将采用基于 FastAPI 的微服务架构。核心是一个 FastAPI 应用,负责接收所有进来的请求,并根据数据库中的配置将请求转发到相应的后端服务,并管理 API Key。
|
5 |
|
6 |
```mermaid
|
7 |
graph LR
|
8 |
Client --> Proxy[FastAPI Proxy]
|
9 |
+
Proxy --> Database[SQLite Database]
|
10 |
Proxy --> BackendA[Backend Service A]
|
11 |
Proxy --> BackendB[Backend Service B]
|
12 |
Proxy --> BackendC[Backend Service C]
|
13 |
+
Database --> Proxy
|
14 |
```
|
15 |
|
16 |
## 设计模式
|
17 |
- **API Gateway Pattern**: FastAPI 应用充当 API 网关,作为客户端访问后端服务的单一入口点。
|
18 |
+
- **Database Pattern**: 使用 SQLite 数据库存储 Key 类别、Key 实例和代理规则配置。
|
19 |
+
- **Configuration Pattern**: 代理规则和 Key 信息通过数据库进行管理,而非静态配置文件。
|
20 |
- **Middleware Pattern**: 利用 FastAPI 的中间件功能处理请求和响应的通用逻辑(如日志、认证等)。
|
21 |
|
22 |
## 关键组件
|
23 |
+
- **FastAPI Application**: 核心应用,处理路由、请求转发和 Key 管理 API。
|
24 |
+
- **SQLite Database**: 存储 Key 类别 (`key_categories`)、API Key 实例 (`api_keys`) 和代理规则 (`proxy_rules`)。
|
25 |
+
- **SQLAlchemy Models**: 定义与数据库表对应的 Python 模型 (`KeyCategory`, `APIKey`)。
|
26 |
+
- **CRUD Operations**: 实现对数据库中 Key 类别和 Key 实例的增删改查逻辑 (`app/crud.py`)。
|
27 |
+
- **Key Selection Logic**: 根据代理规则从 Key 池中选择合适 Key 的逻辑。
|
28 |
- **HTTP Client**: 用于向后端服务发起请求(例如使用 `httpx` 库)。
|
29 |
- **Request/Response Transformer (Optional)**: 根据配置修改请求和响应。
|
30 |
|
31 |
## 数据流
|
32 |
1. 客户端发起请求到 FastAPI Proxy。
|
33 |
2. FastAPI Proxy 接收请求。
|
34 |
+
3. **Proxy 查找数据库**: 根据请求查找数据库 (`proxy_rules` 表) 获取匹配的代理规则。
|
35 |
+
4. **Key 选择 (如果需要)**: 如果匹配规则需要 Key,Proxy 查询数据库 (`api_keys` 表) 获取 Key 池,并根据策略选择一个 Key。
|
36 |
+
5. **应用 Key 并转发**: Proxy 根据规则中定义的应用方式,将选定的 Key 应用到转发请求中。使用 HTTP Client 向后端服务发起请求。
|
37 |
+
6. 后端服务返回响应。
|
38 |
+
7. Request/Response Transformer (如果存在) 修改响应。
|
39 |
+
8. FastAPI Proxy 将响应返回给客户端。
|
memory-bank/techContext.md
CHANGED
@@ -2,8 +2,11 @@
|
|
2 |
|
3 |
## 使用的技术
|
4 |
- **后端框架**: FastAPI (Python)
|
|
|
|
|
|
|
5 |
- **异步 HTTP 客户端**: httpx
|
6 |
-
- **配置管理**:
|
7 |
- **依赖管理**: pip 和 requirements.txt
|
8 |
- **容器化**: Docker
|
9 |
|
@@ -11,16 +14,19 @@
|
|
11 |
1. 克隆仓库。
|
12 |
2. 创建并激活 Conda 虚拟环境 (`conda create -n api-proxy python=3.9` 或使用现有环境,然后 `conda activate api-proxy`)。
|
13 |
3. 安装依赖 (`pip install -r requirements.txt`)。
|
14 |
-
4.
|
|
|
15 |
|
16 |
## 技术约束
|
17 |
- 需要 Python 3.7+。
|
18 |
-
- 依赖于 FastAPI
|
19 |
|
20 |
## 依赖关系
|
21 |
-
- `requirements.txt` 文件列出了所有必要的 Python
|
22 |
|
23 |
## 工具使用模式
|
24 |
- 使用 `uvicorn` 作为 ASGI 服务器运行 FastAPI 应用。
|
25 |
- 使用 `pip` 管理 Python 包。
|
26 |
- 使用 Docker 构建和运行容器。
|
|
|
|
|
|
2 |
|
3 |
## 使用的技术
|
4 |
- **后端框架**: FastAPI (Python)
|
5 |
+
- **数据库**: SQLite
|
6 |
+
- **ORM**: SQLAlchemy
|
7 |
+
- **异步 SQLite 驱动**: aiosqlite
|
8 |
- **异步 HTTP 客户端**: httpx
|
9 |
+
- **配置管理**: 数据库用于存储代理规则和 Key 信息。
|
10 |
- **依赖管理**: pip 和 requirements.txt
|
11 |
- **容器化**: Docker
|
12 |
|
|
|
14 |
1. 克隆仓库。
|
15 |
2. 创建并激活 Conda 虚拟环境 (`conda create -n api-proxy python=3.9` 或使用现有环境,然后 `conda activate api-proxy`)。
|
16 |
3. 安装依赖 (`pip install -r requirements.txt`)。
|
17 |
+
4. **创建数据库**: 运行数据库初始化脚本或命令(例如 `sqlite3 ./api_proxy.db "..."`)。
|
18 |
+
5. 运行应用 (`uvicorn app:app --reload`)。
|
19 |
|
20 |
## 技术约束
|
21 |
- 需要 Python 3.7+。
|
22 |
+
- 依赖于 FastAPI, httpx, SQLAlchemy, aiosqlite 库。
|
23 |
|
24 |
## 依赖关系
|
25 |
+
- `requirements.txt` 文件列出了所有必要的 Python 依赖,包括 FastAPI, httpx, SQLAlchemy, aiosqlite。
|
26 |
|
27 |
## 工具使用模式
|
28 |
- 使用 `uvicorn` 作为 ASGI 服务器运行 FastAPI 应用。
|
29 |
- 使用 `pip` 管理 Python 包。
|
30 |
- 使用 Docker 构建和运行容器。
|
31 |
+
- 使用 `sqlite3` 命令或 SQLAlchemy 进行数据库管理。
|
32 |
+
- 使用 `pytest` 运行单元测试。
|
requirements.txt
CHANGED
@@ -1,3 +1,5 @@
|
|
1 |
fastapi
|
2 |
uvicorn[standard]
|
3 |
httpx
|
|
|
|
|
|
1 |
fastapi
|
2 |
uvicorn[standard]
|
3 |
httpx
|
4 |
+
SQLAlchemy
|
5 |
+
aiosqlite
|
tests/test_crud.py
ADDED
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
from sqlalchemy import create_engine
|
3 |
+
from sqlalchemy.orm import sessionmaker, Session # Import Session
|
4 |
+
from app.database import Base, get_db, KeyCategory, APIKey # Import Base, get_db, and models from database
|
5 |
+
from app import crud # Import crud from app.crud
|
6 |
+
|
7 |
+
# Use an in-memory SQLite database for testing
|
8 |
+
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
|
9 |
+
|
10 |
+
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
11 |
+
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
12 |
+
|
13 |
+
# Override the get_db dependency for testing
|
14 |
+
def override_get_db():
|
15 |
+
try:
|
16 |
+
db = TestingSessionLocal()
|
17 |
+
yield db
|
18 |
+
finally:
|
19 |
+
db.close()
|
20 |
+
|
21 |
+
# Fixture to create and drop tables for each test
|
22 |
+
@pytest.fixture(scope="function")
|
23 |
+
def db_session():
|
24 |
+
Base.metadata.create_all(bind=engine)
|
25 |
+
db = TestingSessionLocal()
|
26 |
+
yield db
|
27 |
+
db.close()
|
28 |
+
Base.metadata.drop_all(bind=engine)
|
29 |
+
|
30 |
+
# --- Test KeyCategory CRUD ---
|
31 |
+
|
32 |
+
def test_create_key_category(db_session: Session):
|
33 |
+
category = crud.create_key_category(db_session, name="test_llm", type="llm", tags=["llm", "test"])
|
34 |
+
assert category.id is not None
|
35 |
+
assert category.name == "test_llm"
|
36 |
+
assert category.type == "llm"
|
37 |
+
assert category.tags == ["llm", "test"]
|
38 |
+
|
39 |
+
def test_get_key_category(db_session: Session):
|
40 |
+
created_category = crud.create_key_category(db_session, name="get_test", type="test")
|
41 |
+
retrieved_category = crud.get_key_category(db_session, created_category.id)
|
42 |
+
assert retrieved_category is not None
|
43 |
+
assert retrieved_category.name == "get_test"
|
44 |
+
|
45 |
+
def test_get_key_category_by_name(db_session: Session):
|
46 |
+
crud.create_key_category(db_session, name="get_by_name_test", type="test")
|
47 |
+
retrieved_category = crud.get_key_category_by_name(db_session, "get_by_name_test")
|
48 |
+
assert retrieved_category is not None
|
49 |
+
assert retrieved_category.name == "get_by_name_test"
|
50 |
+
|
51 |
+
def test_get_key_categories(db_session: Session):
|
52 |
+
crud.create_key_category(db_session, name="list_test_1", type="test")
|
53 |
+
crud.create_key_category(db_session, name="list_test_2", type="test")
|
54 |
+
categories = crud.get_key_categories(db_session)
|
55 |
+
assert len(categories) >= 2 # Account for potential other categories if not in-memory db
|
56 |
+
|
57 |
+
def test_update_key_category(db_session: Session):
|
58 |
+
created_category = crud.create_key_category(db_session, name="update_test", type="old_type")
|
59 |
+
updated_category = crud.update_key_category(db_session, created_category.id, name="updated_test", type="new_type", tags=["updated"])
|
60 |
+
assert updated_category.name == "updated_test"
|
61 |
+
assert updated_category.type == "new_type"
|
62 |
+
assert updated_category.tags == ["updated"]
|
63 |
+
|
64 |
+
def test_delete_key_category(db_session: Session):
|
65 |
+
created_category = crud.create_key_category(db_session, name="delete_test", type="test")
|
66 |
+
deleted_category = crud.delete_key_category(db_session, created_category.id)
|
67 |
+
assert deleted_category is not None
|
68 |
+
retrieved_category = crud.get_key_category(db_session, created_category.id)
|
69 |
+
assert retrieved_category is None
|
70 |
+
|
71 |
+
# --- Test APIKey CRUD ---
|
72 |
+
|
73 |
+
def test_create_api_key(db_session: Session):
|
74 |
+
category = crud.create_key_category(db_session, name="key_cat_for_key", type="test")
|
75 |
+
api_key = crud.create_api_key(db_session, value="test_key_value", category_id=category.id)
|
76 |
+
assert api_key.id is not None
|
77 |
+
assert api_key.value == "test_key_value"
|
78 |
+
assert api_key.category_id == category.id
|
79 |
+
assert api_key.status == "active"
|
80 |
+
|
81 |
+
def test_get_api_key(db_session: Session):
|
82 |
+
category = crud.create_key_category(db_session, name="get_key_cat", type="test")
|
83 |
+
created_key = crud.create_api_key(db_session, value="get_test_key", category_id=category.id)
|
84 |
+
retrieved_key = crud.get_api_key(db_session, created_key.id)
|
85 |
+
assert retrieved_key is not None
|
86 |
+
assert retrieved_key.value == "get_test_key"
|
87 |
+
|
88 |
+
def test_get_api_keys(db_session: Session):
|
89 |
+
category1 = crud.create_key_category(db_session, name="list_key_cat_1", type="test")
|
90 |
+
category2 = crud.create_key_category(db_session, name="list_key_cat_2", type="test")
|
91 |
+
crud.create_api_key(db_session, value="list_key_1", category_id=category1.id)
|
92 |
+
crud.create_api_key(db_session, value="list_key_2", category_id=category1.id)
|
93 |
+
crud.create_api_key(db_session, value="list_key_3", category_id=category2.id)
|
94 |
+
|
95 |
+
all_keys = crud.get_api_keys(db_session)
|
96 |
+
assert len(all_keys) >= 3
|
97 |
+
|
98 |
+
category1_keys = crud.get_api_keys(db_session, category_id=category1.id)
|
99 |
+
assert len(category1_keys) == 2
|
100 |
+
|
101 |
+
active_keys = crud.get_api_keys(db_session, status="active")
|
102 |
+
assert len(active_keys) >= 3 # All created keys are active by default
|
103 |
+
|
104 |
+
inactive_keys = crud.get_api_keys(db_session, status="inactive")
|
105 |
+
assert len(inactive_keys) == 0
|
106 |
+
|
107 |
+
def test_update_api_key(db_session: Session):
|
108 |
+
category1 = crud.create_key_category(db_session, name="update_key_cat_1", type="test")
|
109 |
+
category2 = crud.create_key_category(db_session, name="update_key_cat_2", type="test")
|
110 |
+
created_key = crud.create_api_key(db_session, value="old_key_value", category_id=category1.id, status="active")
|
111 |
+
|
112 |
+
updated_key = crud.update_api_key(db_session, created_key.id, value="new_key_value", category_id=category2.id, status="inactive")
|
113 |
+
assert updated_key.value == "new_key_value"
|
114 |
+
assert updated_key.category_id == category2.id
|
115 |
+
assert updated_key.status == "inactive"
|
116 |
+
|
117 |
+
def test_delete_api_key(db_session: Session):
|
118 |
+
category = crud.create_key_category(db_session, name="delete_key_cat", type="test")
|
119 |
+
created_key = crud.create_api_key(db_session, value="delete_test_key", category_id=category.id)
|
120 |
+
deleted_key = crud.delete_api_key(db_session, created_key.id)
|
121 |
+
assert deleted_key is not None
|
122 |
+
retrieved_key = crud.get_api_key(db_session, created_key.id)
|
123 |
+
assert retrieved_key is None
|
124 |
+
|
125 |
+
# --- Test Key Selection Logic Placeholder ---
|
126 |
+
|
127 |
+
def test_get_available_keys_for_category(db_session: Session):
|
128 |
+
category = crud.create_key_category(db_session, name="available_key_cat", type="test")
|
129 |
+
crud.create_api_key(db_session, value="key1", category_id=category.id, status="active")
|
130 |
+
crud.create_api_key(db_session, value="key2", category_id=category.id, status="inactive")
|
131 |
+
crud.create_api_key(db_session, value="key3", category_id=category.id, status="active")
|
132 |
+
|
133 |
+
available_keys = crud.get_available_keys_for_category(db_session, category.id)
|
134 |
+
assert len(available_keys) == 2
|
135 |
+
assert all(key.status == "active" for key in available_keys)
|
136 |
+
assert {key.value for key in available_keys} == {"key1", "key3"}
|
137 |
+
|
138 |
+
def test_select_key_from_pool_no_keys(db_session: Session):
|
139 |
+
category = crud.create_key_category(db_session, name="empty_pool_cat", type="test")
|
140 |
+
selected_key = crud.select_key_from_pool(db_session, category.id)
|
141 |
+
assert selected_key is None
|
142 |
+
|
143 |
+
def test_select_key_from_pool_basic(db_session: Session):
|
144 |
+
category = crud.create_key_category(db_session, name="basic_pool_cat", type="test")
|
145 |
+
key1 = crud.create_api_key(db_session, value="key1", category_id=category.id, status="active")
|
146 |
+
key2 = crud.create_api_key(db_session, value="key2", category_id=category.id, status="active")
|
147 |
+
|
148 |
+
# Basic selection returns the first one found
|
149 |
+
selected_key = crud.select_key_from_pool(db_session, category.id)
|
150 |
+
assert selected_key is not None
|
151 |
+
# The order might depend on DB implementation, but it should be one of the active keys
|
152 |
+
assert selected_key.value in ["key1", "key2"]
|