优化鉴权系统

This commit is contained in:
2026-07-03 21:54:19 +08:00
parent 7e65fadd83
commit 98a92b83fa
11 changed files with 539 additions and 149 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash DEEPSEEK_MODEL=deepseek-v4-flash
DEFAULT_SYSTEM_PROMPT=You are a helpful assistant. DEFAULT_SYSTEM_PROMPT=You are a helpful assistant.
CHAT_DATA_DIR=data CHAT_DATA_DIR=data
API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret-key"} USER_DB_PATH=data/users.db
MAX_TOOL_ROUNDS=5 MAX_TOOL_ROUNDS=5
MAX_TOOL_CALLS_PER_TURN=10 MAX_TOOL_CALLS_PER_TURN=10
TOOL_TIMEOUT_SECONDS=5 TOOL_TIMEOUT_SECONDS=5
+25 -9
View File
@@ -5,8 +5,9 @@
## 代码结构 ## 代码结构
- `routes.py`:集中定义所有 HTTP 路由。 - `routes.py`:集中定义所有 HTTP 路由。
- `auth.py`校验 API key 白名单并识别当前用户 - `auth.py`根据 `user_id` 识别当前用户,校验请求 `X-API-Key`
- `schemas.py`:集中定义 API 请求和响应结构。 - `schemas.py`:集中定义 API 请求和响应结构。
- `users.py`:基于 SQLite 的用户注册、登录与 API Key 持久化。
- `domain.py`:定义会话、消息和工具调用的持久化模型。 - `domain.py`:定义会话、消息和工具调用的持久化模型。
- `tools.py`:集中定义工具注册表、参数结构和工具函数。 - `tools.py`:集中定义工具注册表、参数结构和工具函数。
- `service.py`:处理对话、Agent 循环和业务规则。 - `service.py`:处理对话、Agent 循环和业务规则。
@@ -23,12 +24,27 @@ uv run uvicorn main:app --reload
服务默认运行在 `http://127.0.0.1:8000`,交互式 API 文档位于 `/docs` 服务默认运行在 `http://127.0.0.1:8000`,交互式 API 文档位于 `/docs`
服务会自动加载项目根目录的 `.env`,已有系统环境变量优先级更高。可配置 `DEEPSEEK_BASE_URL``DEEPSEEK_MODEL``DEFAULT_SYSTEM_PROMPT``CHAT_DATA_DIR` 和工具调用限制,完整示例见 `.env.example`。第一版应只使用一个 Uvicorn worker。 服务会自动加载项目根目录的 `.env`,已有系统环境变量优先级更高。可配置 `DEEPSEEK_BASE_URL``DEEPSEEK_MODEL``DEFAULT_SYSTEM_PROMPT``CHAT_DATA_DIR``USER_DB_PATH` 和工具调用限制,完整示例见 `.env.example`。第一版应只使用一个 Uvicorn worker。
所有业务接口都必须通过 `X-API-Key` 请求头提供访问密钥。白名单使用用户到密钥的 JSON 映射: 用户名、密码哈希、UUID 及 API Key 保存在 SQLite 中(默认路径 `USER_DB_PATH=data/users.db`,随服务启动自动建表)。所有业务接口都必须通过 `X-API-Key` 请求头提供访问密钥,服务端从数据库中查找比对以识别当前用户。
```text ## 注册与登录
API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret-key"}
注册用户(用户名已存在返回 `409`):
```bash
curl -X POST http://127.0.0.1:8000/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"小明","password":"your-password"}'
```
用用户名、密码换取持久化的 API Key(凭据错误返回 `401`):
```bash
curl -X POST http://127.0.0.1:8000/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"小明","password":"your-password"}'
# => {"user_id":"...","api_key":"YOUR_API_KEY"}
``` ```
## 使用 ## 使用
@@ -37,7 +53,7 @@ API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret
```bash ```bash
curl -X POST http://127.0.0.1:8000/sessions \ curl -X POST http://127.0.0.1:8000/sessions \
-H 'X-API-Key: replace-with-a-secret-key' \ -H 'X-API-Key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"system_prompt":"你是一个简洁的中文助手。"}' -d '{"system_prompt":"你是一个简洁的中文助手。"}'
``` ```
@@ -46,7 +62,7 @@ curl -X POST http://127.0.0.1:8000/sessions \
```bash ```bash
curl -X POST http://127.0.0.1:8000/sessions/SESSION_ID/messages \ curl -X POST http://127.0.0.1:8000/sessions/SESSION_ID/messages \
-H 'X-API-Key: replace-with-a-secret-key' \ -H 'X-API-Key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"content":"你好,请记住我的名字是小明。"}' -d '{"content":"你好,请记住我的名字是小明。"}'
``` ```
@@ -63,14 +79,14 @@ API 返回及存储的每条消息都包含 UTC `created_at`。只有模型完
```bash ```bash
curl http://127.0.0.1:8000/sessions/SESSION_ID/messages \ curl http://127.0.0.1:8000/sessions/SESSION_ID/messages \
-H 'X-API-Key: replace-with-a-secret-key' -H 'X-API-Key: YOUR_API_KEY'
``` ```
查询当前用户的累计 API 调用次数和 token 用量: 查询当前用户的累计 API 调用次数和 token 用量:
```bash ```bash
curl http://127.0.0.1:8000/usage \ curl http://127.0.0.1:8000/usage \
-H 'X-API-Key: replace-with-a-secret-key' -H 'X-API-Key: YOUR_API_KEY'
``` ```
每个会话 JSON 顶层保存所属 `user_id`、成功聊天次数及累计 token。`/usage` 会扫描并汇总当前用户的全部会话,不使用独立统计文件。创建会话、查询历史和查询用量不计入 `api_calls` 每个会话 JSON 顶层保存所属 `user_id`、成功聊天次数及累计 token。`/usage` 会扫描并汇总当前用户的全部会话,不使用独立统计文件。创建会话、查询历史和查询用量不计入 `api_calls`
+7 -3
View File
@@ -10,11 +10,13 @@ from routes import router
from service import ChatService from service import ChatService
from storage import JsonSessionStorage from storage import JsonSessionStorage
from tools import ToolRegistry from tools import ToolRegistry
from users import UserStore
def create_app( def create_app(
settings: Settings | None = None, settings: Settings | None = None,
client: AsyncOpenAI | None = None, client: AsyncOpenAI | None = None,
user_store: UserStore | None = None,
) -> FastAPI: ) -> FastAPI:
resolved_settings = settings or Settings.from_env() resolved_settings = settings or Settings.from_env()
@@ -29,6 +31,10 @@ def create_app(
base_url=resolved_settings.base_url, base_url=resolved_settings.base_url,
) )
store = user_store or UserStore(resolved_settings.user_db_path)
await store.init()
app.state.user_store = store
app.state.chat_service = ChatService( app.state.chat_service = ChatService(
storage=JsonSessionStorage(resolved_settings.data_dir), storage=JsonSessionStorage(resolved_settings.data_dir),
client=resolved_client, client=resolved_client,
@@ -38,9 +44,7 @@ def create_app(
max_tool_rounds=resolved_settings.max_tool_rounds, max_tool_rounds=resolved_settings.max_tool_rounds,
max_tool_calls_per_turn=resolved_settings.max_tool_calls_per_turn, max_tool_calls_per_turn=resolved_settings.max_tool_calls_per_turn,
) )
app.state.authenticator = ApiKeyAuthenticator( app.state.authenticator = ApiKeyAuthenticator(store)
resolved_settings.api_key_whitelist
)
yield yield
if client is None: if client is None:
+7 -19
View File
@@ -1,31 +1,19 @@
from dataclasses import dataclass
import hmac
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException, Request, Security, status from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from users import AuthenticatedUser, UserStore
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@dataclass(frozen=True, slots=True)
class AuthenticatedUser:
user_id: str
class ApiKeyAuthenticator: class ApiKeyAuthenticator:
def __init__(self, whitelist: dict[str, str]) -> None: def __init__(self, user_store: UserStore) -> None:
if not whitelist: self._user_store = user_store
raise ValueError("API_KEY_WHITELIST must contain at least one user")
self._whitelist = whitelist.copy()
def authenticate(self, api_key: str | None) -> AuthenticatedUser | None: async def authenticate(self, api_key: str | None) -> AuthenticatedUser | None:
if not api_key: return await self._user_store.authenticate(api_key)
return None
for user_id, allowed_key in self._whitelist.items():
if hmac.compare_digest(api_key, allowed_key):
return AuthenticatedUser(user_id=user_id)
return None
async def require_api_key( async def require_api_key(
@@ -33,7 +21,7 @@ async def require_api_key(
api_key: Annotated[str | None, Security(api_key_header)], api_key: Annotated[str | None, Security(api_key_header)],
) -> AuthenticatedUser: ) -> AuthenticatedUser:
authenticator: ApiKeyAuthenticator = request.app.state.authenticator authenticator: ApiKeyAuthenticator = request.app.state.authenticator
user = authenticator.authenticate(api_key) user = await authenticator.authenticate(api_key)
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
+5 -33
View File
@@ -1,8 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
import json
from pathlib import Path
import os import os
from uuid import UUID from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -15,7 +13,7 @@ class Settings:
model: str model: str
default_system_prompt: str default_system_prompt: str
data_dir: Path data_dir: Path
api_key_whitelist: dict[str, str] user_db_path: Path
max_tool_rounds: int = 5 max_tool_rounds: int = 5
max_tool_calls_per_turn: int = 10 max_tool_calls_per_turn: int = 10
tool_timeout_seconds: float = 5.0 tool_timeout_seconds: float = 5.0
@@ -23,6 +21,7 @@ class Settings:
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
load_dotenv(dotenv_path=Path.cwd() / ".env", override=False) load_dotenv(dotenv_path=Path.cwd() / ".env", override=False)
data_dir = Path(os.getenv("CHAT_DATA_DIR", "data"))
return cls( return cls(
port=int(os.getenv("SERVICE_PORT", "8000")), port=int(os.getenv("SERVICE_PORT", "8000")),
api_key=os.getenv("DEEPSEEK_API_KEY"), api_key=os.getenv("DEEPSEEK_API_KEY"),
@@ -31,38 +30,11 @@ class Settings:
default_system_prompt=os.getenv( default_system_prompt=os.getenv(
"DEFAULT_SYSTEM_PROMPT", "You are a helpful assistant." "DEFAULT_SYSTEM_PROMPT", "You are a helpful assistant."
), ),
data_dir=Path(os.getenv("CHAT_DATA_DIR", "data")), data_dir=data_dir,
api_key_whitelist=_parse_api_key_whitelist( user_db_path=Path(os.getenv("USER_DB_PATH", str(data_dir / "users.db"))),
os.getenv("API_KEY_WHITELIST", "{}")
),
max_tool_rounds=int(os.getenv("MAX_TOOL_ROUNDS", "5")), max_tool_rounds=int(os.getenv("MAX_TOOL_ROUNDS", "5")),
max_tool_calls_per_turn=int( max_tool_calls_per_turn=int(
os.getenv("MAX_TOOL_CALLS_PER_TURN", "10") os.getenv("MAX_TOOL_CALLS_PER_TURN", "10")
), ),
tool_timeout_seconds=float(os.getenv("TOOL_TIMEOUT_SECONDS", "5")), tool_timeout_seconds=float(os.getenv("TOOL_TIMEOUT_SECONDS", "5")),
) )
def _parse_api_key_whitelist(value: str) -> dict[str, str]:
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("API_KEY_WHITELIST must be valid JSON") from exc
if not isinstance(parsed, dict):
raise ValueError("API_KEY_WHITELIST must map user UUIDs to API keys")
normalized: dict[str, str] = {}
for user_id, api_key in parsed.items():
if not isinstance(user_id, str) or not isinstance(api_key, str) or not api_key:
raise ValueError("API_KEY_WHITELIST must map user UUIDs to API keys")
try:
normalized_user_id = str(UUID(user_id))
except ValueError as exc:
raise ValueError("API_KEY_WHITELIST user IDs must be valid UUIDs") from exc
if normalized_user_id in normalized:
raise ValueError("API_KEY_WHITELIST contains duplicate user UUIDs")
normalized[normalized_user_id] = api_key
if len(set(normalized.values())) != len(normalized):
raise ValueError("API keys in API_KEY_WHITELIST must be unique")
return normalized
+2
View File
@@ -5,6 +5,8 @@ description = "A minimal persistent DeepSeek chat API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"aiosqlite>=0.20.0",
"bcrypt>=4.0.0",
"fastapi>=0.115.0", "fastapi>=0.115.0",
"httpx[socks]>=0.28.0", "httpx[socks]>=0.28.0",
"openai>=1.55.0", "openai>=1.55.0",
+59
View File
@@ -4,6 +4,10 @@ from auth import CurrentUser
from schemas import ( from schemas import (
CreateSessionRequest, CreateSessionRequest,
CreateSessionResponse, CreateSessionResponse,
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
SendMessageRequest, SendMessageRequest,
SendMessageResponse, SendMessageResponse,
SessionHistoryResponse, SessionHistoryResponse,
@@ -11,6 +15,12 @@ from schemas import (
) )
from service import ChatProviderError, ChatService from service import ChatProviderError, ChatService
from storage import SessionNotFoundError, SessionStorageError from storage import SessionNotFoundError, SessionStorageError
from users import (
InvalidCredentialsError,
UserStore,
UserStoreError,
UsernameExistsError,
)
router = APIRouter() router = APIRouter()
@@ -20,6 +30,55 @@ def get_chat_service(request: Request) -> ChatService:
return request.app.state.chat_service return request.app.state.chat_service
def get_user_store(request: Request) -> UserStore:
return request.app.state.user_store
@router.post(
"/auth/register",
response_model=RegisterResponse,
status_code=status.HTTP_201_CREATED,
tags=["auth"],
)
async def register(
body: RegisterRequest,
request: Request,
) -> RegisterResponse:
store = get_user_store(request)
try:
user = await store.register(body.username, body.password)
except UsernameExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="username already exists",
) from exc
except UserStoreError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return RegisterResponse(user_id=user.user_id, created_at=user.created_at)
@router.post(
"/auth/login",
response_model=LoginResponse,
tags=["auth"],
)
async def login(
body: LoginRequest,
request: Request,
) -> LoginResponse:
store = get_user_store(request)
try:
user = await store.login(body.username, body.password)
except InvalidCredentialsError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
) from exc
except UserStoreError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return LoginResponse(user_id=user.user_id, api_key=user.api_key)
@router.post( @router.post(
"/sessions", "/sessions",
response_model=CreateSessionResponse, response_model=CreateSessionResponse,
+52
View File
@@ -72,3 +72,55 @@ class UserUsageResponse(BaseModel):
completion_tokens: int completion_tokens: int
total_tokens: int total_tokens: int
updated_at: datetime updated_at: datetime
class RegisterRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
if not value.strip():
raise ValueError("username must not be blank")
return value
@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
if not value.strip():
raise ValueError("password must not be blank")
return value
class RegisterResponse(BaseModel):
user_id: str
created_at: datetime
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
if not value.strip():
raise ValueError("username must not be blank")
return value
@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
if not value.strip():
raise ValueError("password must not be blank")
return value
class LoginResponse(BaseModel):
user_id: str
api_key: str
+151 -78
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import json import json
from pathlib import Path from pathlib import Path
@@ -10,13 +11,20 @@ from openai import APIConnectionError
import pytest import pytest
from app import create_app from app import create_app
from config import Settings, _parse_api_key_whitelist from config import Settings
from service import ChatService from service import ChatService
from storage import JsonSessionStorage from storage import JsonSessionStorage
from users import UserStore
TEST_USER_ID = "11111111-1111-4111-8111-111111111111" @dataclass
OTHER_USER_ID = "22222222-2222-4222-8222-222222222222" class AppHarness:
client: TestClient
provider: "FakeClient"
test_user_id: str
other_user_id: str
test_api_key: str
other_api_key: str
class FakeCompletions: class FakeCompletions:
@@ -43,6 +51,8 @@ class FakeCompletions:
total_tokens=12, total_tokens=12,
), ),
) )
class FakeClient: class FakeClient:
def __init__(self) -> None: def __init__(self) -> None:
self.completions = FakeCompletions() self.completions = FakeCompletions()
@@ -50,8 +60,14 @@ class FakeClient:
@pytest.fixture @pytest.fixture
def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]: def app_client(tmp_path: Path) -> AppHarness:
provider = FakeClient() provider = FakeClient()
db_path = tmp_path / "users.db"
store = UserStore(db_path)
asyncio.run(store.init())
test_user = asyncio.run(store.register("test-user", "test-password"))
other_user = asyncio.run(store.register("other-user", "other-password"))
settings = Settings( settings = Settings(
port=8000, port=8000,
api_key=None, api_key=None,
@@ -59,15 +75,19 @@ def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]:
model="deepseek-v4-flash", model="deepseek-v4-flash",
default_system_prompt="default prompt", default_system_prompt="default prompt",
data_dir=tmp_path, data_dir=tmp_path,
api_key_whitelist={ user_db_path=db_path,
TEST_USER_ID: "test-api-key",
OTHER_USER_ID: "other-api-key",
},
) )
app = create_app(settings=settings, client=provider) # type: ignore[arg-type] app = create_app(settings=settings, client=provider, user_store=store) # type: ignore[arg-type]
with TestClient(app) as client: with TestClient(app) as client:
client.headers["X-API-Key"] = "test-api-key" client.headers["X-API-Key"] = test_user.api_key
yield client, provider yield AppHarness(
client=client,
provider=provider,
test_user_id=test_user.user_id,
other_user_id=other_user.user_id,
test_api_key=test_user.api_key,
other_api_key=other_user.api_key,
)
def create_session(client: TestClient, **body: str) -> str: def create_session(client: TestClient, **body: str) -> str:
@@ -81,6 +101,8 @@ def test_settings_load_api_key_from_dotenv(
) -> None: ) -> None:
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
monkeypatch.delenv("USER_DB_PATH", raising=False)
monkeypatch.delenv("CHAT_DATA_DIR", raising=False)
(tmp_path / ".env").write_text("DEEPSEEK_API_KEY=test-key\n") (tmp_path / ".env").write_text("DEEPSEEK_API_KEY=test-key\n")
settings = Settings.from_env() settings = Settings.from_env()
@@ -88,22 +110,8 @@ def test_settings_load_api_key_from_dotenv(
assert settings.api_key == "test-key" assert settings.api_key == "test-key"
def test_api_key_whitelist_requires_and_normalizes_uuid() -> None: def test_all_api_endpoints_require_valid_api_key(app_client: AppHarness) -> None:
parsed = _parse_api_key_whitelist( client = app_client.client
'{"7A007F92-86D0-4F51-931F-007B9F0B8B0E":"key"}'
)
assert parsed == {
"7a007f92-86d0-4f51-931f-007b9f0b8b0e": "key"
}
with pytest.raises(ValueError, match="valid UUIDs"):
_parse_api_key_whitelist('{"custom-name":"key"}')
def test_all_api_endpoints_require_valid_api_key(
client_and_provider: tuple[TestClient, FakeClient]
) -> None:
client, _ = client_and_provider
del client.headers["X-API-Key"] del client.headers["X-API-Key"]
missing = client.post("/sessions") missing = client.post("/sessions")
@@ -115,10 +123,93 @@ def test_all_api_endpoints_require_valid_api_key(
assert invalid.json() == {"detail": "api_key is required"} assert invalid.json() == {"detail": "api_key is required"}
def test_register_creates_user_and_returns_user_id(app_client: AppHarness) -> None:
client = app_client.client
response = client.post(
"/auth/register",
json={"username": "fresh-user", "password": "fresh-password"},
)
assert response.status_code == 201
body = response.json()
assert isinstance(body["user_id"], str)
assert body["user_id"] != app_client.test_user_id
assert body["created_at"].endswith("Z")
datetime.fromisoformat(body["created_at"])
login = client.post(
"/auth/login",
json={"username": "fresh-user", "password": "fresh-password"},
)
assert login.status_code == 200
assert login.json()["user_id"] == body["user_id"]
assert isinstance(login.json()["api_key"], str)
assert login.json()["api_key"]
def test_register_rejects_duplicate_username_with_409(app_client: AppHarness) -> None:
client = app_client.client
response = client.post(
"/auth/register",
json={"username": "test-user", "password": "any-password"},
)
assert response.status_code == 409
assert response.json() == {"detail": "username already exists"}
def test_register_rejects_blank_fields(app_client: AppHarness) -> None:
client = app_client.client
blank_username = client.post(
"/auth/register", json={"username": " ", "password": "secret"}
)
blank_password = client.post(
"/auth/register", json={"username": "new-user", "password": " "}
)
assert blank_username.status_code == 422
assert blank_password.status_code == 422
def test_login_returns_persistent_api_key(app_client: AppHarness) -> None:
client = app_client.client
response = client.post(
"/auth/login",
json={"username": "test-user", "password": "test-password"},
)
assert response.status_code == 200
body = response.json()
assert body["user_id"] == app_client.test_user_id
assert body["api_key"] == app_client.test_api_key
def test_login_invalid_credentials_returns_401(app_client: AppHarness) -> None:
client = app_client.client
wrong_password = client.post(
"/auth/login",
json={"username": "test-user", "password": "wrong"},
)
unknown_user = client.post(
"/auth/login",
json={"username": "no-such-user", "password": "anything"},
)
assert wrong_password.status_code == 401
assert wrong_password.json() == {"detail": "invalid username or password"}
assert unknown_user.status_code == 401
assert unknown_user.json() == {"detail": "invalid username or password"}
def test_usage_endpoint_returns_current_user_totals( def test_usage_endpoint_returns_current_user_totals(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path app_client: AppHarness, tmp_path: Path
) -> None: ) -> None:
client, _ = client_and_provider client = app_client.client
session_id = create_session(client) session_id = create_session(client)
message = client.post( message = client.post(
f"/sessions/{session_id}/messages", json={"content": "hello"} f"/sessions/{session_id}/messages", json={"content": "hello"}
@@ -128,7 +219,7 @@ def test_usage_endpoint_returns_current_user_totals(
assert message.status_code == 200 assert message.status_code == 200
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["user_id"] == TEST_USER_ID assert response.json()["user_id"] == app_client.test_user_id
assert response.json()["api_calls"] == 1 assert response.json()["api_calls"] == 1
assert response.json()["prompt_tokens"] == 10 assert response.json()["prompt_tokens"] == 10
assert response.json()["completion_tokens"] == 2 assert response.json()["completion_tokens"] == 2
@@ -137,21 +228,19 @@ def test_usage_endpoint_returns_current_user_totals(
def test_create_session_uses_default_prompt( def test_create_session_uses_default_prompt(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path app_client: AppHarness, tmp_path: Path
) -> None: ) -> None:
client, _ = client_and_provider client = app_client.client
session_id = create_session(client) session_id = create_session(client)
data = json.loads((tmp_path / f"{session_id}.json").read_text()) data = json.loads((tmp_path / f"{session_id}.json").read_text())
assert data["user_id"] == TEST_USER_ID assert data["user_id"] == app_client.test_user_id
assert data["system_prompt"] == "default prompt" assert data["system_prompt"] == "default prompt"
assert data["messages"] == [] assert data["messages"] == []
def test_create_session_without_body( def test_create_session_without_body(app_client: AppHarness) -> None:
client_and_provider: tuple[TestClient, FakeClient] client = app_client.client
) -> None:
client, _ = client_and_provider
response = client.post("/sessions") response = client.post("/sessions")
@@ -159,10 +248,8 @@ def test_create_session_without_body(
assert response.json()["system_prompt"] == "default prompt" assert response.json()["system_prompt"] == "default prompt"
def test_create_session_accepts_custom_prompt( def test_create_session_accepts_custom_prompt(app_client: AppHarness) -> None:
client_and_provider: tuple[TestClient, FakeClient] client = app_client.client
) -> None:
client, _ = client_and_provider
response = client.post("/sessions", json={"system_prompt": "回答中文"}) response = client.post("/sessions", json={"system_prompt": "回答中文"})
assert response.status_code == 201 assert response.status_code == 201
@@ -170,9 +257,9 @@ def test_create_session_accepts_custom_prompt(
def test_multi_turn_request_contains_full_history( def test_multi_turn_request_contains_full_history(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path app_client: AppHarness, tmp_path: Path
) -> None: ) -> None:
client, provider = client_and_provider client, provider = app_client.client, app_client.provider
session_id = create_session(client, system_prompt="system") session_id = create_session(client, system_prompt="system")
first = client.post( first = client.post(
@@ -207,10 +294,8 @@ def test_multi_turn_request_contains_full_history(
assert all(message["created_at"].endswith("Z") for message in data["messages"]) assert all(message["created_at"].endswith("Z") for message in data["messages"])
def test_invalid_session_and_blank_message( def test_invalid_session_and_blank_message(app_client: AppHarness) -> None:
client_and_provider: tuple[TestClient, FakeClient] client = app_client.client
) -> None:
client, _ = client_and_provider
missing = client.post( missing = client.post(
"/sessions/00000000-0000-0000-0000-000000000000/messages", "/sessions/00000000-0000-0000-0000-000000000000/messages",
@@ -226,9 +311,9 @@ def test_invalid_session_and_blank_message(
def test_upstream_failure_does_not_change_history( def test_upstream_failure_does_not_change_history(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path app_client: AppHarness, tmp_path: Path
) -> None: ) -> None:
client, provider = client_and_provider client, provider = app_client.client, app_client.provider
session_id = create_session(client) session_id = create_session(client)
before = (tmp_path / f"{session_id}.json").read_text() before = (tmp_path / f"{session_id}.json").read_text()
provider.completions.error = APIConnectionError(request=object()) # type: ignore[arg-type] provider.completions.error = APIConnectionError(request=object()) # type: ignore[arg-type]
@@ -242,9 +327,9 @@ def test_upstream_failure_does_not_change_history(
def test_corrupt_session_returns_500( def test_corrupt_session_returns_500(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path app_client: AppHarness, tmp_path: Path
) -> None: ) -> None:
client, _ = client_and_provider client = app_client.client
session_id = create_session(client) session_id = create_session(client)
(tmp_path / f"{session_id}.json").write_text("not-json") (tmp_path / f"{session_id}.json").write_text("not-json")
@@ -256,9 +341,9 @@ def test_corrupt_session_returns_500(
def test_legacy_messages_without_timestamp_remain_usable( def test_legacy_messages_without_timestamp_remain_usable(
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path app_client: AppHarness, tmp_path: Path
) -> None: ) -> None:
client, _ = client_and_provider client = app_client.client
session_id = create_session(client) session_id = create_session(client)
path = tmp_path / f"{session_id}.json" path = tmp_path / f"{session_id}.json"
data = json.loads(path.read_text()) data = json.loads(path.read_text())
@@ -272,16 +357,14 @@ def test_legacy_messages_without_timestamp_remain_usable(
assert response.status_code == 200 assert response.status_code == 200
migrated = json.loads(path.read_text()) migrated = json.loads(path.read_text())
assert migrated["user_id"] == TEST_USER_ID assert migrated["user_id"] == app_client.test_user_id
assert all("created_at" in message for message in migrated["messages"]) assert all("created_at" in message for message in migrated["messages"])
def test_session_cannot_be_accessed_by_another_user( def test_session_cannot_be_accessed_by_another_user(app_client: AppHarness) -> None:
client_and_provider: tuple[TestClient, FakeClient] client = app_client.client
) -> None:
client, _ = client_and_provider
session_id = create_session(client) session_id = create_session(client)
other_headers = {"X-API-Key": "other-api-key"} other_headers = {"X-API-Key": app_client.other_api_key}
history = client.get( history = client.get(
f"/sessions/{session_id}/messages", headers=other_headers f"/sessions/{session_id}/messages", headers=other_headers
@@ -296,10 +379,8 @@ def test_session_cannot_be_accessed_by_another_user(
assert message.status_code == 404 assert message.status_code == 404
def test_stream_parameter_is_rejected( def test_stream_parameter_is_rejected(app_client: AppHarness) -> None:
client_and_provider: tuple[TestClient, FakeClient] client, provider = app_client.client, app_client.provider
) -> None:
client, provider = client_and_provider
session_id = create_session(client) session_id = create_session(client)
response = client.post( response = client.post(
@@ -311,17 +392,11 @@ def test_stream_parameter_is_rejected(
assert provider.completions.calls == [] assert provider.completions.calls == []
def test_get_session_history_returns_all_messages( def test_get_session_history_returns_all_messages(app_client: AppHarness) -> None:
client_and_provider: tuple[TestClient, FakeClient] client = app_client.client
) -> None:
client, _ = client_and_provider
session_id = create_session(client, system_prompt="history prompt") session_id = create_session(client, system_prompt="history prompt")
client.post( client.post(f"/sessions/{session_id}/messages", json={"content": "first"})
f"/sessions/{session_id}/messages", json={"content": "first"} client.post(f"/sessions/{session_id}/messages", json={"content": "second"})
)
client.post(
f"/sessions/{session_id}/messages", json={"content": "second"}
)
response = client.get(f"/sessions/{session_id}/messages") response = client.get(f"/sessions/{session_id}/messages")
@@ -341,15 +416,13 @@ def test_get_session_history_returns_all_messages(
def test_get_session_history_handles_empty_and_missing_sessions( def test_get_session_history_handles_empty_and_missing_sessions(
client_and_provider: tuple[TestClient, FakeClient] app_client: AppHarness
) -> None: ) -> None:
client, _ = client_and_provider client = app_client.client
session_id = create_session(client) session_id = create_session(client)
empty = client.get(f"/sessions/{session_id}/messages") empty = client.get(f"/sessions/{session_id}/messages")
missing = client.get( missing = client.get("/sessions/00000000-0000-0000-0000-000000000000/messages")
"/sessions/00000000-0000-0000-0000-000000000000/messages"
)
assert empty.status_code == 200 assert empty.status_code == 200
assert empty.json()["messages"] == [] assert empty.json()["messages"] == []
+160
View File
@@ -0,0 +1,160 @@
from dataclasses import dataclass
from datetime import UTC, datetime
import hmac
import secrets
from pathlib import Path
from uuid import uuid4
import aiosqlite
import bcrypt
class UsernameExistsError(Exception):
pass
class InvalidCredentialsError(Exception):
pass
class UserStoreError(Exception):
pass
@dataclass(frozen=True, slots=True)
class RegisteredUser:
user_id: str
api_key: str
created_at: datetime
@dataclass(frozen=True, slots=True)
class AuthenticatedUser:
user_id: str
_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL
);
"""
class UserStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
async def init(self) -> None:
try:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(self.db_path) as db:
await db.execute("PRAGMA journal_mode=WAL;")
await db.execute("PRAGMA busy_timeout=5000;")
await db.execute("PRAGMA foreign_keys=ON;")
await db.executescript(_SCHEMA)
await db.commit()
except aiosqlite.Error as exc:
raise UserStoreError("could not initialize user store") from exc
async def register(self, username: str, password: str) -> RegisteredUser:
password_hash = bcrypt.hashpw(
password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
now = datetime.now(UTC)
user = RegisteredUser(
user_id=str(uuid4()),
api_key=secrets.token_urlsafe(32),
created_at=now,
)
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT INTO users (user_id, username, password_hash, api_key, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(
user.user_id,
username,
password_hash,
user.api_key,
now.isoformat(),
),
)
await db.commit()
except aiosqlite.IntegrityError as exc:
raise UsernameExistsError(username) from exc
except aiosqlite.Error as exc:
raise UserStoreError("could not register user") from exc
return user
async def login(self, username: str, password: str) -> RegisteredUser:
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"SELECT user_id, password_hash, api_key, created_at "
"FROM users WHERE username = ?",
(username,),
)
row = await cursor.fetchone()
except aiosqlite.Error as exc:
raise UserStoreError("could not query user") from exc
if row is None:
_run_constant_time_password_check()
raise InvalidCredentialsError("invalid username or password")
user_id, password_hash, api_key, created_at = row
try:
password_ok = bcrypt.checkpw(
password.encode("utf-8"), password_hash.encode("utf-8")
)
except ValueError as exc:
raise UserStoreError("stored password hash is corrupt") from exc
if not password_ok:
raise InvalidCredentialsError("invalid username or password")
try:
parsed_created_at = datetime.fromisoformat(created_at)
except ValueError as exc:
raise UserStoreError("stored created_at is corrupt") from exc
return RegisteredUser(
user_id=user_id,
api_key=api_key,
created_at=parsed_created_at,
)
async def authenticate(self, api_key: str | None) -> AuthenticatedUser | None:
if not api_key:
return None
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"SELECT api_key, user_id FROM users WHERE api_key = ?",
(api_key,),
)
row = await cursor.fetchone()
except aiosqlite.Error:
return None
if row is None:
return None
stored_key, user_id = row
if not hmac.compare_digest(api_key, stored_key):
return None
return AuthenticatedUser(user_id=user_id)
def _run_constant_time_password_check() -> None:
"""Perform a dummy bcrypt comparison to keep timing roughly constant.
Equalizes the response time between an unknown username (no hash to
compare against) and a wrong password for a known username, reducing the
feasibility of username enumeration via timing.
"""
dummy_hash = bcrypt.gensalt()
bcrypt.checkpw(b"dummy-password", dummy_hash)
Generated
+64
View File
@@ -2,6 +2,15 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.14"
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.4" version = "0.0.4"
@@ -32,6 +41,57 @@ wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, { url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.6.17" version = "2026.6.17"
@@ -363,6 +423,8 @@ name = "simple-chat-api"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" },
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx", extra = ["socks"] }, { name = "httpx", extra = ["socks"] },
{ name = "openai" }, { name = "openai" },
@@ -377,6 +439,8 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.0" },
{ name = "openai", specifier = ">=1.55.0" }, { name = "openai", specifier = ">=1.55.0" },